diff --git a/core/__pycache__/decorators.cpython-311.pyc b/core/__pycache__/decorators.cpython-311.pyc new file mode 100644 index 0000000..aa6e44c Binary files /dev/null and b/core/__pycache__/decorators.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 5c123ea..dc278a1 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index a04f671..6f02ad6 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 0314732..0551f82 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/decorators.py b/core/decorators.py new file mode 100644 index 0000000..4045601 --- /dev/null +++ b/core/decorators.py @@ -0,0 +1,12 @@ +from django.contrib.auth.models import Group +from django.http import HttpResponseForbidden + +def group_required(group_names): + def decorator(view_func): + def _wrapped_view(request, *args, **kwargs): + if request.user.is_authenticated: + if request.user.groups.filter(name__in=group_names).exists() or request.user.is_superuser: + return view_func(request, *args, **kwargs) + return HttpResponseForbidden("You don't have permission to access this page.") + return _wrapped_view + return decorator diff --git a/core/etenders_api.py b/core/etenders_api.py new file mode 100644 index 0000000..b3311e1 --- /dev/null +++ b/core/etenders_api.py @@ -0,0 +1,18 @@ +import requests + +ETENDERS_API_URL = "https://www.etenders.gov.za/api/v1/tenders" + +def get_tenders(): + """ + Fetches a list of tenders from the eTenders API. + + Returns: + list: A list of tenders as dictionaries, or None if an error occurs. + """ + try: + response = requests.get(ETENDERS_API_URL) + response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx) + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error fetching tenders from eTenders API: {e}") + return None diff --git a/core/management/__init__.py b/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/__init__.py b/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/import_tenders.py b/core/management/commands/import_tenders.py new file mode 100644 index 0000000..8606429 --- /dev/null +++ b/core/management/commands/import_tenders.py @@ -0,0 +1,52 @@ +from django.core.management.base import BaseCommand +from core.etenders_api import get_tenders +from core.models import Tender, Company +from datetime import datetime + +class Command(BaseCommand): + help = 'Imports tenders from the eTenders API' + + def handle(self, *args, **options): + self.stdout.write('Importing tenders from eTenders API...') + tenders_data = get_tenders() + + if not tenders_data: + self.stdout.write(self.style.WARNING('No tenders found or API is unavailable.')) + return + + # For now, let's assume a default company exists. + # A more robust implementation would be to get the company from the tender data. + default_company, created = Company.objects.get_or_create(name='Default Company') + + imported_count = 0 + for tender_data in tenders_data: + # Assuming the API returns these fields. This will need to be adjusted + # based on the actual API response. + title = tender_data.get('title') + description = tender_data.get('description') + deadline_str = tender_data.get('deadline') + + if not all([title, description, deadline_str]): + self.stdout.write(self.style.WARNING(f'Skipping tender with incomplete data: {tender_data}')) + continue + + try: + deadline = datetime.fromisoformat(deadline_str) + except ValueError: + self.stdout.write(self.style.WARNING(f'Skipping tender with invalid deadline format: {deadline_str}')) + continue + + # Check if a tender with the same title and company already exists. + if Tender.objects.filter(title=title, company=default_company).exists(): + self.stdout.write(self.style.NOTICE(f'Tender "{title}" already exists. Skipping.')) + continue + + Tender.objects.create( + company=default_company, + title=title, + description=description, + deadline=deadline, + ) + imported_count += 1 + + self.stdout.write(self.style.SUCCESS(f'Successfully imported {imported_count} new tenders.')) diff --git a/core/migrations/0004_tender_status.py b/core/migrations/0004_tender_status.py new file mode 100644 index 0000000..fcb3c9e --- /dev/null +++ b/core/migrations/0004_tender_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-15 19:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_document_uploaded_by'), + ] + + operations = [ + migrations.AddField( + model_name='tender', + name='status', + field=models.CharField(choices=[('opportunity-discovery', 'Opportunity Discovery'), ('qualification', 'Qualification / Go–No Go'), ('tender-registration', 'Tender Registration'), ('bid-planning', 'Bid Planning'), ('team-task-assignment', 'Team & Task Assignment'), ('document-collection-drafting', 'Document Collection & Drafting'), ('internal-review-compliance-check', 'Internal Review & Compliance Check'), ('approvals-sign-off', 'Approvals & Sign‑off'), ('submission-confirmation', 'Submission & Confirmation'), ('post-bid-review-analytics', 'Post‑Bid Review & Analytics')], default='opportunity-discovery', max_length=50), + ), + ] diff --git a/core/migrations/__pycache__/0004_tender_status.cpython-311.pyc b/core/migrations/__pycache__/0004_tender_status.cpython-311.pyc new file mode 100644 index 0000000..2f4405a Binary files /dev/null and b/core/migrations/__pycache__/0004_tender_status.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 02632c4..34e63f5 100644 --- a/core/models.py +++ b/core/models.py @@ -23,10 +23,23 @@ class Membership(models.Model): return f"{self.user.username} - {self.company.name} ({self.role})" class Tender(models.Model): + STATUS_CHOICES = [ + ('opportunity-discovery', 'Opportunity Discovery'), + ('qualification', 'Qualification / Go–No Go'), + ('tender-registration', 'Tender Registration'), + ('bid-planning', 'Bid Planning'), + ('team-task-assignment', 'Team & Task Assignment'), + ('document-collection-drafting', 'Document Collection & Drafting'), + ('internal-review-compliance-check', 'Internal Review & Compliance Check'), + ('approvals-sign-off', 'Approvals & Sign‑off'), + ('submission-confirmation', 'Submission & Confirmation'), + ('post-bid-review-analytics', 'Post‑Bid Review & Analytics'), + ] company = models.ForeignKey(Company, on_delete=models.CASCADE) title = models.CharField(max_length=255) description = models.TextField() deadline = models.DateTimeField() + status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='opportunity-discovery') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/core/templates/core/tender_detail.html b/core/templates/core/tender_detail.html index 6bc6306..e59f04e 100644 --- a/core/templates/core/tender_detail.html +++ b/core/templates/core/tender_detail.html @@ -11,6 +11,7 @@

Description:

{{ tender.description }}

Deadline: {{ tender.deadline }}

+

Status: {{ tender.get_status_display }}

+
+
+

Workflow

+
+
+ {% if request.user.is_authenticated and request.user.groups.all|length > 0 %} + {% if 'Head of Bids' in request.user.groups.all.0.name or 'Bid Administrator' in request.user.groups.all.0.name %} + {% if next_status %} +

The current step is complete. Ready to move to the next step.

+ Approve and move to: {{ next_status.1 }} + {% else %} +

This tender is in the final workflow step.

+ {% endif %} + {% endif %} + {% else %} +

You do not have permission to advance the workflow.

+ {% endif %} +
+
+

Bids

diff --git a/core/urls.py b/core/urls.py index 2048395..f70b047 100644 --- a/core/urls.py +++ b/core/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path('create_company/', views.create_company, name='create_company'), path('company//tenders/', views.tender_list, name='tender_list'), path('tender//', views.tender_detail, name='tender_detail'), + path('tender//update_status//', views.update_tender_status, name='update_tender_status'), path('company//create_tender/', views.create_tender, name='create_tender'), path('tender//update/', views.update_tender, name='update_tender'), path('tender//delete/', views.delete_tender, name='delete_tender'), diff --git a/core/views.py b/core/views.py index 912dfa7..3f919e1 100644 --- a/core/views.py +++ b/core/views.py @@ -3,6 +3,7 @@ from django.contrib.auth import login, authenticate, logout from django.contrib.auth.decorators import login_required from .forms import SignUpForm, CompanyForm, TenderForm, BidForm, DocumentForm, NoteForm, ApprovalForm from .models import Company, Membership, Tender, Bid, Document, Note, Approval +from .decorators import group_required def home(request): return render(request, "core/index.html") @@ -81,16 +82,41 @@ def tender_detail(request, tender_id): doc_form = DocumentForm() note_form = NoteForm() + # Workflow logic + current_status_index = [i for i, (value, display) in enumerate(Tender.STATUS_CHOICES) if value == tender.status][0] + next_status = Tender.STATUS_CHOICES[current_status_index + 1] if current_status_index + 1 < len(Tender.STATUS_CHOICES) else None + + context = { 'tender': tender, 'bids': bids, 'documents': documents, 'notes': notes, 'doc_form': doc_form, - 'note_form': note_form + 'note_form': note_form, + 'next_status': next_status, } return render(request, 'core/tender_detail.html', context) +@login_required +@group_required(['Head of Bids', 'Bid Administrator']) +def update_tender_status(request, tender_id, next_status): + tender = get_object_or_404(Tender, pk=tender_id) + + # Find the index of the current status + current_status_index = [i for i, (value, display) in enumerate(Tender.STATUS_CHOICES) if value == tender.status][0] + + # Find the index of the next status + next_status_index = [i for i, (value, display) in enumerate(Tender.STATUS_CHOICES) if value == next_status][0] + + # Check if the next status is valid + if next_status_index == current_status_index + 1: + tender.status = next_status + tender.save() + + return redirect('tender_detail', tender_id=tender.id) + + @login_required def create_tender(request, company_id): company = get_object_or_404(Company, pk=company_id) diff --git a/requirements.txt b/requirements.txt index e22994c..cbc66a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Django==5.2.7 mysqlclient==2.2.7 python-dotenv==1.1.1 +requests==2.31.0