You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
323 lines
9.2 KiB
323 lines
9.2 KiB
import random
|
|
from django.conf import settings
|
|
from django.db import models
|
|
from django.utils.text import slugify
|
|
from django.utils.translation import gettext_lazy as _
|
|
from apps.profiles.models.institution import Institution
|
|
|
|
|
|
class Project(models.Model):
|
|
class Status(models.TextChoices):
|
|
PLANNING = 'planning', _('Planning')
|
|
ACTIVE = 'active', _('Active / In Progress')
|
|
ON_HOLD = 'on_hold', _('On Hold')
|
|
COMPLETED = 'completed', _('Completed')
|
|
|
|
title = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_('Project Title')
|
|
)
|
|
slug = models.SlugField(
|
|
max_length=255,
|
|
unique=True,
|
|
allow_unicode=True,
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_('Slug / URL Identifier')
|
|
)
|
|
description = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_('Project Description & Objectives')
|
|
)
|
|
owner_institution = models.ForeignKey(
|
|
Institution,
|
|
on_delete=models.CASCADE,
|
|
related_name='owned_projects',
|
|
verbose_name=_('Lead / Owner Institution')
|
|
)
|
|
collaborating_institutions = models.ManyToManyField(
|
|
Institution,
|
|
blank=True,
|
|
related_name='collaborative_projects',
|
|
verbose_name=_('Collaborating Institutions')
|
|
)
|
|
category = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_('Project Category'),
|
|
help_text=_('e.g. Cultural Diplomacy, Educational Academy, Interfaith Dialogue, Humanitarian Relief')
|
|
)
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=Status.choices,
|
|
default=Status.ACTIVE,
|
|
verbose_name=_('Project Status')
|
|
)
|
|
progress_percentage = models.PositiveSmallIntegerField(
|
|
default=0,
|
|
verbose_name=_('Progress Percentage (0-100)')
|
|
)
|
|
budget = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_('Estimated Budget'),
|
|
help_text=_('e.g. $50,000 or €35,000')
|
|
)
|
|
team_size = models.PositiveIntegerField(
|
|
default=1,
|
|
verbose_name=_('Team Size')
|
|
)
|
|
cover_image = models.ImageField(
|
|
upload_to='projects/covers/%Y/%m/',
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_('Cover Image')
|
|
)
|
|
start_date = models.DateField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_('Start Date')
|
|
)
|
|
estimated_end_date = models.DateField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_('Estimated End Date')
|
|
)
|
|
actual_end_date = models.DateField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_('Actual Completion Date')
|
|
)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_projects',
|
|
verbose_name=_('Created By')
|
|
)
|
|
created_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
verbose_name=_('Created At')
|
|
)
|
|
updated_at = models.DateTimeField(
|
|
auto_now=True,
|
|
verbose_name=_('Updated At')
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ('-created_at',)
|
|
verbose_name = _('Collaborative Project')
|
|
verbose_name_plural = _('Collaborative Projects')
|
|
|
|
def __str__(self):
|
|
return f"{self.title} ({self.owner_institution.name})"
|
|
|
|
def save(self, *args, **kwargs):
|
|
is_new = self.pk is None
|
|
if not self.slug:
|
|
base_slug = slugify(self.title, allow_unicode=True) or f"project-{random.randint(1000, 9999)}"
|
|
slug = base_slug
|
|
counter = 1
|
|
while Project.objects.filter(slug=slug).exclude(pk=self.pk).exists():
|
|
slug = f"{base_slug}-{counter}"
|
|
counter += 1
|
|
self.slug = slug
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
if is_new:
|
|
self._create_default_kanban_columns()
|
|
|
|
def _create_default_kanban_columns(self):
|
|
default_columns = [
|
|
{'name': 'To Do', 'code': 'todo', 'order': 1, 'color': '#94a3b8'},
|
|
{'name': 'In Progress', 'code': 'in_progress', 'order': 2, 'color': '#3b82f6'},
|
|
{'name': 'Review', 'code': 'review', 'order': 3, 'color': '#f59e0b'},
|
|
{'name': 'Done', 'code': 'done', 'order': 4, 'color': '#10b981'},
|
|
]
|
|
for col in default_columns:
|
|
KanbanColumn.objects.create(
|
|
project=self,
|
|
name=col['name'],
|
|
code=col['code'],
|
|
order=col['order'],
|
|
color=col['color']
|
|
)
|
|
|
|
def can_user_edit(self, user):
|
|
if not user or not user.is_authenticated:
|
|
return False
|
|
if getattr(user, 'is_super_admin', False) or getattr(user, 'is_regional_admin', False):
|
|
return True
|
|
# Check owner institution
|
|
if self.owner_institution.is_editor(user):
|
|
return True
|
|
# Check collaborating institutions
|
|
for collab in self.collaborating_institutions.all():
|
|
if collab.is_editor(user):
|
|
return True
|
|
return False
|
|
|
|
|
|
class KanbanColumn(models.Model):
|
|
project = models.ForeignKey(
|
|
Project,
|
|
on_delete=models.CASCADE,
|
|
related_name='columns',
|
|
verbose_name=_('Project')
|
|
)
|
|
name = models.CharField(
|
|
max_length=100,
|
|
verbose_name=_('Column Name')
|
|
)
|
|
code = models.CharField(
|
|
max_length=50,
|
|
default='todo',
|
|
verbose_name=_('Column Code Identifier')
|
|
)
|
|
order = models.IntegerField(
|
|
default=0,
|
|
verbose_name=_('Display Order')
|
|
)
|
|
color = models.CharField(
|
|
max_length=20,
|
|
default='#6366f1',
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_('Badge / Header Color')
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ('order', 'id')
|
|
verbose_name = _('Kanban Column')
|
|
verbose_name_plural = _('Kanban Columns')
|
|
|
|
def __str__(self):
|
|
return f"{self.project.title} - {self.name}"
|
|
|
|
|
|
class KanbanTask(models.Model):
|
|
class Priority(models.TextChoices):
|
|
LOW = 'low', _('Low')
|
|
MEDIUM = 'medium', _('Medium')
|
|
HIGH = 'high', _('High')
|
|
URGENT = 'urgent', _('Urgent')
|
|
|
|
project = models.ForeignKey(
|
|
Project,
|
|
on_delete=models.CASCADE,
|
|
related_name='tasks',
|
|
verbose_name=_('Project')
|
|
)
|
|
column = models.ForeignKey(
|
|
KanbanColumn,
|
|
on_delete=models.CASCADE,
|
|
related_name='tasks',
|
|
verbose_name=_('Kanban Column')
|
|
)
|
|
title = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_('Task Title')
|
|
)
|
|
description = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_('Task Description')
|
|
)
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=Priority.choices,
|
|
default=Priority.MEDIUM,
|
|
verbose_name=_('Priority')
|
|
)
|
|
assignee = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='assigned_tasks',
|
|
verbose_name=_('Assignee User')
|
|
)
|
|
due_date = models.DateField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_('Due Date')
|
|
)
|
|
labels = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
verbose_name=_('Task Labels')
|
|
)
|
|
order = models.IntegerField(
|
|
default=0,
|
|
verbose_name=_('Display Order within Column')
|
|
)
|
|
created_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
verbose_name=_('Created At')
|
|
)
|
|
updated_at = models.DateTimeField(
|
|
auto_now=True,
|
|
verbose_name=_('Updated At')
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ('order', '-updated_at')
|
|
verbose_name = _('Kanban Task')
|
|
verbose_name_plural = _('Kanban Tasks')
|
|
|
|
def __str__(self):
|
|
return f"{self.title} ({self.column.name})"
|
|
|
|
|
|
class ProjectDocument(models.Model):
|
|
class DocumentType(models.TextChoices):
|
|
MOU = 'mou', _('MOU / Bilateral Agreement')
|
|
REPORT = 'report', _('Progress / Milestone Report')
|
|
CONTRACT = 'contract', _('Contract / Agreement')
|
|
OTHER = 'other', _('Other Document')
|
|
|
|
project = models.ForeignKey(
|
|
Project,
|
|
on_delete=models.CASCADE,
|
|
related_name='documents',
|
|
verbose_name=_('Project')
|
|
)
|
|
title = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_('Document Title')
|
|
)
|
|
file = models.FileField(
|
|
upload_to='projects/docs/%Y/%m/',
|
|
verbose_name=_('Document File')
|
|
)
|
|
doc_type = models.CharField(
|
|
max_length=50,
|
|
choices=DocumentType.choices,
|
|
default=DocumentType.MOU,
|
|
verbose_name=_('Document Type')
|
|
)
|
|
uploaded_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='uploaded_project_documents',
|
|
verbose_name=_('Uploaded By')
|
|
)
|
|
uploaded_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
verbose_name=_('Uploaded At')
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ('-uploaded_at',)
|
|
verbose_name = _('Project Document')
|
|
verbose_name_plural = _('Project Documents')
|
|
|
|
def __str__(self):
|
|
return f"{self.project.title} - {self.title}"
|