This commit is contained in:
Flatlogic Bot 2025-11-15 19:14:03 +00:00
parent 873880f7d1
commit 1955ecf8b3
16 changed files with 163 additions and 1 deletions

Binary file not shown.

12
core/decorators.py Normal file
View File

@ -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

18
core/etenders_api.py Normal file
View File

@ -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

View File

View File

View File

@ -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.'))

View File

@ -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 / GoNo 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 & Signoff'), ('submission-confirmation', 'Submission & Confirmation'), ('post-bid-review-analytics', 'PostBid Review & Analytics')], default='opportunity-discovery', max_length=50),
),
]

View File

@ -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 / GoNo 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 & Signoff'),
('submission-confirmation', 'Submission & Confirmation'),
('post-bid-review-analytics', 'PostBid 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)

View File

@ -11,6 +11,7 @@
<p><strong>Description:</strong></p>
<p>{{ tender.description }}</p>
<p><strong>Deadline:</strong> {{ tender.deadline }}</p>
<p><strong>Status:</strong> <span class="badge bg-info">{{ tender.get_status_display }}</span></p>
</div>
<div class="card-footer">
<a href="{% url 'tender_list' tender.company.id %}" class="btn btn-secondary">Back to Tenders</a>
@ -19,6 +20,26 @@
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h2 class="h4">Workflow</h2>
</div>
<div class="card-body">
{% 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 %}
<p>The current step is complete. Ready to move to the next step.</p>
<a href="{% url 'update_tender_status' tender.id next_status.0 %}" class="btn btn-success">Approve and move to: {{ next_status.1 }}</a>
{% else %}
<p>This tender is in the final workflow step.</p>
{% endif %}
{% endif %}
{% else %}
<p>You do not have permission to advance the workflow.</p>
{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="h4">Bids</h2>

View File

@ -10,6 +10,7 @@ urlpatterns = [
path('create_company/', views.create_company, name='create_company'),
path('company/<int:company_id>/tenders/', views.tender_list, name='tender_list'),
path('tender/<int:tender_id>/', views.tender_detail, name='tender_detail'),
path('tender/<int:tender_id>/update_status/<str:next_status>/', views.update_tender_status, name='update_tender_status'),
path('company/<int:company_id>/create_tender/', views.create_tender, name='create_tender'),
path('tender/<int:tender_id>/update/', views.update_tender, name='update_tender'),
path('tender/<int:tender_id>/delete/', views.delete_tender, name='delete_tender'),

View File

@ -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)

View File

@ -1,3 +1,4 @@
Django==5.2.7
mysqlclient==2.2.7
python-dotenv==1.1.1
requests==2.31.0