diff --git a/scipost_django/finances/models.py b/scipost_django/finances/models.py deleted file mode 100644 index b9fcd140da5a53fb90707f47c851c2aecfba6b55..0000000000000000000000000000000000000000 --- a/scipost_django/finances/models.py +++ /dev/null @@ -1,411 +0,0 @@ -__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" -__license__ = "AGPL v3" - - -import datetime -import os -from re import I -from typing import TYPE_CHECKING - -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.contrib.contenttypes.fields import GenericForeignKey -from django.db import models -from django.db.models import Sum -from django.db.models.signals import pre_save -from django.dispatch import receiver -from django.urls import reverse -from django.utils import timezone -from django.utils.html import format_html - -from .constants import SUBSIDY_TYPES, SUBSIDY_TYPE_SPONSORSHIPAGREEMENT, SUBSIDY_STATUS -from .managers import SubsidyQuerySet, SubsidyPaymentQuerySet, SubsidyAttachmentQuerySet -from .utils import id_to_slug - -from scipost.storage import SecureFileStorage - -if TYPE_CHECKING: - from organizations.models import Organization - - -class Subsidy(models.Model): - """ - A subsidy given to SciPost by an Organization. - Any fund given to SciPost, in any form, must be associated - to a corresponding Subsidy instance. - - This can for example be: - - * a Sponsorship agreement - * an incidental grant - * a development grant for a specific purpose - * a Collaboration Agreement - * a donation - - The date_from field represents the date at which the Subsidy was formally agreed, - or (e.g. for Sponsorship Agreements) the date at which the agreement enters into force. - The date_until field is optional, and represents (where applicable) the date - after which the object of the Subsidy is officially terminated. - """ - - organization = models.ForeignKey["Organization"]( - "organizations.Organization", on_delete=models.CASCADE - ) - subsidy_type = models.CharField(max_length=256, choices=SUBSIDY_TYPES) - description = models.TextField() - amount = models.PositiveIntegerField(help_text="in € (rounded)") - amount_publicly_shown = models.BooleanField(default=True) - status = models.CharField(max_length=32, choices=SUBSIDY_STATUS) - paid_on = models.DateField(blank=True, null=True) - date_from = models.DateField() - date_until = models.DateField(blank=True, null=True) - renewable = models.BooleanField(null=True) - renewal_of = models.ManyToManyField( - "self", related_name="renewed_by", symmetrical=False, blank=True - ) - - objects = SubsidyQuerySet.as_manager() - - class Meta: - verbose_name_plural = "subsidies" - ordering = ["-date_from"] - - def __str__(self): - if self.amount_publicly_shown: - return format_html( - "{}: €{} from {}, for {}", - self.date_from, - self.amount, - self.organization, - self.description, - ) - return format_html( - "{}: from {}, for {}", self.date_from, self.organization, self.description - ) - - def get_absolute_url(self): - return reverse("finances:subsidy_details", args=(self.id,)) - - def value_in_year(self, year): - """ - Normalize the value of the subsidy per year. - """ - if self.date_until is None: - if self.date_from.year == year: - return self.amount - return 0 - if self.date_from.year <= year and self.date_until.year >= year: - # keep it simple: for all years covered, spread evenly - nr_years_covered = self.date_until.year - self.date_from.year + 1 - return int(self.amount / nr_years_covered) - return 0 - - @property - def renewal_action_date(self): - if self.date_until and self.subsidy_type == SUBSIDY_TYPE_SPONSORSHIPAGREEMENT: - return self.date_until - datetime.timedelta(days=122) - return "-" - - @property - def renewal_action_date_color_class(self): - if self.date_until and self.renewable: - if self.renewed_by.exists(): - return "transparent" - today = datetime.date.today() - if self.date_until < today + datetime.timedelta(days=122): - return "danger" - elif self.date_until < today + datetime.timedelta(days=153): - return "warning" - return "success" - return "transparent" - - @property - def date_until_color_class(self): - if self.date_until and self.renewable: - if self.renewed_by.exists(): - return "transparent" - today = datetime.date.today() - if self.date_until < today: - return "warning" - else: - return "success" - return "transparent" - - @property - def payments_all_scheduled(self): - """ - Verify that there exist SubsidyPayment objects covering full amount. - """ - return self.amount == self.payments.aggregate(Sum("amount"))["amount__sum"] - - -class SubsidyPayment(models.Model): - subsidy = models.ForeignKey( - "finances.Subsidy", - related_name="payments", - on_delete=models.CASCADE, - ) - reference = models.CharField(max_length=64, unique=True) - amount = models.PositiveIntegerField(help_text="in €") - date_scheduled = models.DateField() - invoice = models.OneToOneField( - "finances.SubsidyAttachment", - on_delete=models.SET_NULL, - blank=True, - null=True, - related_name="invoice_for", - ) - proof_of_payment = models.OneToOneField( - "finances.SubsidyAttachment", - on_delete=models.SET_NULL, - blank=True, - null=True, - related_name="proof_of_payment_for", - ) - - objects = SubsidyPaymentQuerySet.as_manager() - - def __str__(self): - return f"payment {self.reference} for {self.subsidy}" - - @property - def status(self): - if self.paid: - return "paid" - if self.invoiced: - return "invoiced" - return "scheduled" - - @property - def invoiced(self): - return self.invoice is not None - - @property - def invoice_date(self): - return self.invoice.date if self.invoice else None - - @property - def paid(self): - return self.proof_of_payment is not None - - @property - def payment_date(self): - return self.proof_of_payment.date if self.proof_of_payment else None - - -def subsidy_attachment_path(instance: "SubsidyAttachment", filename: str) -> str: - """ - Save the uploaded SubsidyAttachments to country-specific folders. - """ - if instance.subsidy is None: - return "uploads/finances/subsidies/orphaned/%s" % filename - - return "uploads/finances/subsidies/{0}/{1}/{2}".format( - instance.subsidy.date_from.strftime("%Y"), - instance.subsidy.organization.country, - filename, - ) - - -class SubsidyAttachment(models.Model): - """ - A document related to a Subsidy. - """ - - KIND_AGREEMENT = "agreement" - KIND_INVOICE = "invoice" - KIND_PROOF_OF_PAYMENT = "proofofpayment" - KIND_OTHER = "other" - KIND_CHOICES = ( - (KIND_AGREEMENT, "Agreement"), - (KIND_INVOICE, "Invoice"), - (KIND_PROOF_OF_PAYMENT, "Proof of payment"), - (KIND_OTHER, "Other"), - ) - - VISIBILITY_PUBLIC = "public" - VISIBILITY_INTERNAL = "internal" - VISIBILITY_FINADMINONLY = "finadminonly" - VISIBILITY_CHOICES = ( - (VISIBILITY_PUBLIC, "Publicly visible"), - (VISIBILITY_INTERNAL, "Internal (admin, Org Contacts)"), - (VISIBILITY_FINADMINONLY, "SciPost FinAdmin only"), - ) - - subsidy = models.ForeignKey["Subsidy"]( - "finances.Subsidy", - related_name="attachments", - null=True, - blank=True, - on_delete=models.CASCADE, - ) - - attachment = models.FileField( - max_length=256, - upload_to=subsidy_attachment_path, - storage=SecureFileStorage(), - ) - - git_url = models.URLField( - blank=True, help_text="URL to the file's location in GitLab" - ) - - kind = models.CharField( - max_length=32, - choices=KIND_CHOICES, - default=KIND_AGREEMENT, - ) - - date = models.DateField(blank=True, null=True) - - description = models.TextField(blank=True) - - visibility = models.CharField( - max_length=32, - choices=VISIBILITY_CHOICES, - default=VISIBILITY_FINADMINONLY, - ) - - objects = SubsidyAttachmentQuerySet.as_manager() - - def __str__(self): - return "%s, attachment to %s" % (self.attachment.name, self.subsidy) - - def get_absolute_url(self): - if self.subsidy: - return reverse( - "finances:subsidy_attachment", kwargs={"attachment_id": self.id} - ) - - @property - def filename(self): - return os.path.basename(self.attachment.name) - - @property - def publicly_visible(self): - return self.visibility == self.VISIBILITY_PUBLIC - - def visible_to_user(self, current_user): - if self.publicly_visible or current_user.has_perm( - "scipost.can_manage_subsidies" - ): - return True - if self.subsidy.organization.contactrole_set.filter( - contact__user=current_user - ).exists(): - return True - return False - - -# Delete attachment files with same name if they exist, allowing replacement without name change -@receiver(pre_save, sender=SubsidyAttachment) -def delete_old_attachment_file(sender, instance: SubsidyAttachment, **kwargs): - """ - Replace existing file on update if a new one is provided. - Move file to the new location if the subsidy changes. - """ - if instance.pk and instance.attachment: - old = SubsidyAttachment.objects.get(pk=instance.pk) - if old is None or old.attachment is None: - return - - # Delete old file if it is replaced - if old.attachment != instance.attachment: - old.attachment.delete(save=False) - - # Move file to new location if subsidy changes - if old.subsidy != instance.subsidy: - old_relative_path = old.attachment.name - new_relative_path = subsidy_attachment_path(instance, instance.filename) - - instance.attachment.storage.save(new_relative_path, instance.attachment) - instance.attachment.storage.delete(old_relative_path) - instance.attachment.name = new_relative_path - - -@receiver(models.signals.post_delete, sender=SubsidyAttachment) -def auto_delete_file_on_delete(sender, instance, **kwargs): - """ - Deletes file from filesystem when its object is deleted. - """ - if instance.attachment: - instance.attachment.delete(save=False) - - -########################### -# Work hours registration # -########################### - - -class WorkLog(models.Model): - HOURLY_RATE = 22.0 - - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - comments = models.TextField(blank=True) - log_type = models.CharField(max_length=128, blank=True) - duration = models.DurationField(blank=True, null=True) - work_date = models.DateField(default=timezone.now) - created = models.DateTimeField(auto_now_add=True) - - content_type = models.ForeignKey( - ContentType, blank=True, null=True, on_delete=models.CASCADE - ) - object_id = models.PositiveIntegerField(blank=True, null=True) - content = GenericForeignKey() - - class Meta: - default_related_name = "work_logs" - ordering = ["-work_date", "created"] - - def __str__(self): - return "Log of {0} {1} on {2}".format( - self.user.first_name, self.user.last_name, self.work_date - ) - - @property - def slug(self): - return id_to_slug(self.id) - - -#################### -# Periodic Reports # -#################### - - -class PeriodicReportType(models.Model): - name = models.CharField(max_length=256) - description = models.TextField() - - def __str__(self): - return self.name - - -def periodic_report_upload_path(instance, filename): - return f"uploads/finances/periodic_reports/{instance.for_year}/{filename}" - - -class PeriodicReport(models.Model): - """ - Any form of report (annual, financial, administrative etc). - """ - - _type = models.ForeignKey( - "finances.PeriodicReportType", - on_delete=models.CASCADE, - ) - _file = models.FileField( - upload_to=periodic_report_upload_path, - max_length=256, - ) - created_on = models.DateTimeField(default=timezone.now) - for_year = models.PositiveSmallIntegerField() - - class META: - ordering = ["-for_year", "_type__name"] - - def __str__(self): - return f"{self.for_year} {self._type}" - - def get_absolute_url(self): - if self._file: - return reverse("finances:periodicreport_file", kwargs={"pk": self.id}) diff --git a/scipost_django/finances/models/__init__.py b/scipost_django/finances/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e7fa05003585e3df7c7d7f8ce64ca778d5575f48 --- /dev/null +++ b/scipost_django/finances/models/__init__.py @@ -0,0 +1,20 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from .periodic_report import ( + PeriodicReportType, + periodic_report_upload_path, + PeriodicReport, +) + +from .subsidy import Subsidy + +from .subsidy_payment import SubsidyPayment + +from .subsidy_attachment import ( + subsidy_attachment_path, + SubsidyAttachment, +) + +from .work_log import WorkLog diff --git a/scipost_django/finances/models/periodic_report.py b/scipost_django/finances/models/periodic_report.py new file mode 100644 index 0000000000000000000000000000000000000000..6ff289b8ce5363534503ff0e4edd6e1c45c36a8d --- /dev/null +++ b/scipost_django/finances/models/periodic_report.py @@ -0,0 +1,46 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models +from django.urls import reverse +from django.utils import timezone + + +class PeriodicReportType(models.Model): + name = models.CharField(max_length=256) + description = models.TextField() + + def __str__(self): + return self.name + + +def periodic_report_upload_path(instance, filename): + return f"uploads/finances/periodic_reports/{instance.for_year}/{filename}" + + +class PeriodicReport(models.Model): + """ + Any form of report (annual, financial, administrative etc). + """ + + _type = models.ForeignKey( + "finances.PeriodicReportType", + on_delete=models.CASCADE, + ) + _file = models.FileField( + upload_to=periodic_report_upload_path, + max_length=256, + ) + created_on = models.DateTimeField(default=timezone.now) + for_year = models.PositiveSmallIntegerField() + + class META: + ordering = ["-for_year", "_type__name"] + + def __str__(self): + return f"{self.for_year} {self._type}" + + def get_absolute_url(self): + if self._file: + return reverse("finances:periodicreport_file", kwargs={"pk": self.id}) diff --git a/scipost_django/finances/models/subsidy.py b/scipost_django/finances/models/subsidy.py new file mode 100644 index 0000000000000000000000000000000000000000..dcb8c119836ffcf9716ef3e128b0de276efcd555 --- /dev/null +++ b/scipost_django/finances/models/subsidy.py @@ -0,0 +1,124 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +import datetime + +from django.db import models +from django.db.models import Sum +from django.urls import reverse +from django.utils.html import format_html + +from ..constants import SUBSIDY_TYPES, SUBSIDY_TYPE_SPONSORSHIPAGREEMENT, SUBSIDY_STATUS +from ..managers import SubsidyQuerySet + + +class Subsidy(models.Model): + """ + A subsidy given to SciPost by an Organization. + Any fund given to SciPost, in any form, must be associated + to a corresponding Subsidy instance. + + This can for example be: + + * a Sponsorship agreement + * an incidental grant + * a development grant for a specific purpose + * a Collaboration Agreement + * a donation + + The date_from field represents the date at which the Subsidy was formally agreed, + or (e.g. for Sponsorship Agreements) the date at which the agreement enters into force. + The date_until field is optional, and represents (where applicable) the date + after which the object of the Subsidy is officially terminated. + """ + + organization = models.ForeignKey["Organization"]( + "organizations.Organization", on_delete=models.CASCADE + ) + subsidy_type = models.CharField(max_length=256, choices=SUBSIDY_TYPES) + description = models.TextField() + amount = models.PositiveIntegerField(help_text="in € (rounded)") + amount_publicly_shown = models.BooleanField(default=True) + status = models.CharField(max_length=32, choices=SUBSIDY_STATUS) + paid_on = models.DateField(blank=True, null=True) + date_from = models.DateField() + date_until = models.DateField(blank=True, null=True) + renewable = models.BooleanField(null=True) + renewal_of = models.ManyToManyField( + "self", related_name="renewed_by", symmetrical=False, blank=True + ) + + objects = SubsidyQuerySet.as_manager() + + class Meta: + verbose_name_plural = "subsidies" + ordering = ["-date_from"] + + def __str__(self): + if self.amount_publicly_shown: + return format_html( + "{}: €{} from {}, for {}", + self.date_from, + self.amount, + self.organization, + self.description, + ) + return format_html( + "{}: from {}, for {}", self.date_from, self.organization, self.description + ) + + def get_absolute_url(self): + return reverse("finances:subsidy_details", args=(self.id,)) + + def value_in_year(self, year): + """ + Normalize the value of the subsidy per year. + """ + if self.date_until is None: + if self.date_from.year == year: + return self.amount + return 0 + if self.date_from.year <= year and self.date_until.year >= year: + # keep it simple: for all years covered, spread evenly + nr_years_covered = self.date_until.year - self.date_from.year + 1 + return int(self.amount / nr_years_covered) + return 0 + + @property + def renewal_action_date(self): + if self.date_until and self.subsidy_type == SUBSIDY_TYPE_SPONSORSHIPAGREEMENT: + return self.date_until - datetime.timedelta(days=122) + return "-" + + @property + def renewal_action_date_color_class(self): + if self.date_until and self.renewable: + if self.renewed_by.exists(): + return "transparent" + today = datetime.date.today() + if self.date_until < today + datetime.timedelta(days=122): + return "danger" + elif self.date_until < today + datetime.timedelta(days=153): + return "warning" + return "success" + return "transparent" + + @property + def date_until_color_class(self): + if self.date_until and self.renewable: + if self.renewed_by.exists(): + return "transparent" + today = datetime.date.today() + if self.date_until < today: + return "warning" + else: + return "success" + return "transparent" + + @property + def payments_all_scheduled(self): + """ + Verify that there exist SubsidyPayment objects covering full amount. + """ + return self.amount == self.payments.aggregate(Sum("amount"))["amount__sum"] diff --git a/scipost_django/finances/models/subsidy_attachment.py b/scipost_django/finances/models/subsidy_attachment.py new file mode 100644 index 0000000000000000000000000000000000000000..66d5882b169c32c260b8e614c099c17d3f85140c --- /dev/null +++ b/scipost_django/finances/models/subsidy_attachment.py @@ -0,0 +1,153 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +import os + +from django.db import models +from django.db.models.signals import pre_save +from django.dispatch import receiver +from django.urls import reverse + +from ..managers import SubsidyAttachmentQuerySet + +from scipost.storage import SecureFileStorage + + +def subsidy_attachment_path(instance: "SubsidyAttachment", filename: str) -> str: + """ + Save the uploaded SubsidyAttachments to country-specific folders. + """ + if instance.subsidy is None: + return "uploads/finances/subsidies/orphaned/%s" % filename + + return "uploads/finances/subsidies/{0}/{1}/{2}".format( + instance.subsidy.date_from.strftime("%Y"), + instance.subsidy.organization.country, + filename, + ) + + +class SubsidyAttachment(models.Model): + """ + A document related to a Subsidy. + """ + + KIND_AGREEMENT = "agreement" + KIND_INVOICE = "invoice" + KIND_PROOF_OF_PAYMENT = "proofofpayment" + KIND_OTHER = "other" + KIND_CHOICES = ( + (KIND_AGREEMENT, "Agreement"), + (KIND_INVOICE, "Invoice"), + (KIND_PROOF_OF_PAYMENT, "Proof of payment"), + (KIND_OTHER, "Other"), + ) + + VISIBILITY_PUBLIC = "public" + VISIBILITY_INTERNAL = "internal" + VISIBILITY_FINADMINONLY = "finadminonly" + VISIBILITY_CHOICES = ( + (VISIBILITY_PUBLIC, "Publicly visible"), + (VISIBILITY_INTERNAL, "Internal (admin, Org Contacts)"), + (VISIBILITY_FINADMINONLY, "SciPost FinAdmin only"), + ) + + subsidy = models.ForeignKey["Subsidy"]( + "finances.Subsidy", + related_name="attachments", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + + attachment = models.FileField( + max_length=256, + upload_to=subsidy_attachment_path, + storage=SecureFileStorage(), + ) + + git_url = models.URLField( + blank=True, help_text="URL to the file's location in GitLab" + ) + + kind = models.CharField( + max_length=32, + choices=KIND_CHOICES, + default=KIND_AGREEMENT, + ) + + date = models.DateField(blank=True, null=True) + + description = models.TextField(blank=True) + + visibility = models.CharField( + max_length=32, + choices=VISIBILITY_CHOICES, + default=VISIBILITY_FINADMINONLY, + ) + + objects = SubsidyAttachmentQuerySet.as_manager() + + def __str__(self): + return "%s, attachment to %s" % (self.attachment.name, self.subsidy) + + def get_absolute_url(self): + if self.subsidy: + return reverse( + "finances:subsidy_attachment", kwargs={"attachment_id": self.id} + ) + + @property + def filename(self): + return os.path.basename(self.attachment.name) + + @property + def publicly_visible(self): + return self.visibility == self.VISIBILITY_PUBLIC + + def visible_to_user(self, current_user): + if self.publicly_visible or current_user.has_perm( + "scipost.can_manage_subsidies" + ): + return True + if self.subsidy.organization.contactrole_set.filter( + contact__user=current_user + ).exists(): + return True + return False + + +# Delete attachment files with same name if they exist, allowing replacement without name change +@receiver(pre_save, sender=SubsidyAttachment) +def delete_old_attachment_file(sender, instance: SubsidyAttachment, **kwargs): + """ + Replace existing file on update if a new one is provided. + Move file to the new location if the subsidy changes. + """ + if instance.pk and instance.attachment: + old = SubsidyAttachment.objects.get(pk=instance.pk) + if old is None or old.attachment is None: + return + + # Delete old file if it is replaced + if old.attachment != instance.attachment: + old.attachment.delete(save=False) + + # Move file to new location if subsidy changes + if old.subsidy != instance.subsidy: + old_relative_path = old.attachment.name + new_relative_path = subsidy_attachment_path(instance, instance.filename) + + instance.attachment.storage.save(new_relative_path, instance.attachment) + instance.attachment.storage.delete(old_relative_path) + instance.attachment.name = new_relative_path + + +@receiver(models.signals.post_delete, sender=SubsidyAttachment) +def auto_delete_file_on_delete(sender, instance, **kwargs): + """ + Deletes file from filesystem when its object is deleted. + """ + if instance.attachment: + instance.attachment.delete(save=False) diff --git a/scipost_django/finances/models/subsidy_payment.py b/scipost_django/finances/models/subsidy_payment.py new file mode 100644 index 0000000000000000000000000000000000000000..65543c2950a9d85d2e426d8a9608630df4042c93 --- /dev/null +++ b/scipost_django/finances/models/subsidy_payment.py @@ -0,0 +1,61 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models + +from ..managers import SubsidyPaymentQuerySet + + +class SubsidyPayment(models.Model): + subsidy = models.ForeignKey( + "finances.Subsidy", + related_name="payments", + on_delete=models.CASCADE, + ) + reference = models.CharField(max_length=64, unique=True) + amount = models.PositiveIntegerField(help_text="in €") + date_scheduled = models.DateField() + invoice = models.OneToOneField( + "finances.SubsidyAttachment", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="invoice_for", + ) + proof_of_payment = models.OneToOneField( + "finances.SubsidyAttachment", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="proof_of_payment_for", + ) + + objects = SubsidyPaymentQuerySet.as_manager() + + def __str__(self): + return f"payment {self.reference} for {self.subsidy}" + + @property + def status(self): + if self.paid: + return "paid" + if self.invoiced: + return "invoiced" + return "scheduled" + + @property + def invoiced(self): + return self.invoice is not None + + @property + def invoice_date(self): + return self.invoice.date if self.invoice else None + + @property + def paid(self): + return self.proof_of_payment is not None + + @property + def payment_date(self): + return self.proof_of_payment.date if self.proof_of_payment else None diff --git a/scipost_django/finances/models/work_log.py b/scipost_django/finances/models/work_log.py new file mode 100644 index 0000000000000000000000000000000000000000..b808707ae1b06bb2b976da62c2a2891ec5dbd77d --- /dev/null +++ b/scipost_django/finances/models/work_log.py @@ -0,0 +1,41 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey +from django.db import models +from django.utils import timezone + +from ..utils import id_to_slug + + +class WorkLog(models.Model): + HOURLY_RATE = 22.0 + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + comments = models.TextField(blank=True) + log_type = models.CharField(max_length=128, blank=True) + duration = models.DurationField(blank=True, null=True) + work_date = models.DateField(default=timezone.now) + created = models.DateTimeField(auto_now_add=True) + + content_type = models.ForeignKey( + ContentType, blank=True, null=True, on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField(blank=True, null=True) + content = GenericForeignKey() + + class Meta: + default_related_name = "work_logs" + ordering = ["-work_date", "created"] + + def __str__(self): + return "Log of {0} {1} on {2}".format( + self.user.first_name, self.user.last_name, self.work_date + ) + + @property + def slug(self): + return id_to_slug(self.id)