import datetime import mimetypes import os import typing import uuid from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import RegexValidator, URLValidator from django.db import models from django.db.models.fields.files import FieldFile from django.db.models.signals import post_save from django.dispatch import receiver from django.urls import reverse from django.utils.safestring import mark_safe from markdownx.models import MarkdownxField from shared.models import NameStrMixin from users.models import User from . import settings as app_settings class SubdomainValidatedURLField(models.URLField): validators = [ URLValidator(schemes=("https",)), RegexValidator(regex=r"https:\/\/(.*\.|)pirati.cz(\/|$).*"), ] class OwnPermissionsMixin(models.Model): class Meta: abstract = True permissions = [ ("edit_others", "Upravit cizí"), ("delete_others", "Odstranit cizí"), ] class ContractCountMixin(models.Model): def get_contract_count(self, user) -> None: filter = {"is_approved": True} if not user.has_perm("contract.view_confidential"): filter["is_public"] = True return self.contracts.filter(**filter).count() class Meta: abstract = True class SignatureCountMixin(models.Model): def get_signature_count(self, user) -> None: filter = {"contract__is_approved": True} if not user.has_perm("contract.view_confidential"): filter["contract__is_approved"] = True return self.signatures.filter(**filter).count() class Meta: abstract = True class InlineNameMixin(models.Model): @property def inline_name(self) -> str: result = self.name if self.department: result += f", {self.department}" return result class Meta: abstract = True class CreatedByMixin(models.Model): created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True, related_name="+", verbose_name="Vytvořeno uživatelem", ) # WARNING: exclude in admin class Meta: abstract = True class RepresentativeMixin: @property def name(self): raise NotImplementedError @property def function(self): raise NotImplementedError def __str__(self) -> str: result = str(self.name) if self.function is not None: result += f", {self.function}" return result def get_default_country(): return settings.DEFAULT_COUNTRY class Signee( CreatedByMixin, OwnPermissionsMixin, SignatureCountMixin, InlineNameMixin, models.Model, ): name = models.CharField( max_length=256, verbose_name="Jméno", ) class EntityTypes(models.TextChoices): NATURAL_PERSON = "natural_person", "Fyzická osoba" LEGAL_ENTITY = "legal_entity", "Právnická osoba" BUSINESS_NATURAL_PERSON = "business_natural_person", "Podnikající fyzická osoba" OTHER = "other", "Jiné" entity_type = models.CharField( max_length=23, choices=EntityTypes.choices, default=EntityTypes.LEGAL_ENTITY, verbose_name="Typ", help_text="Důležité označit správně! Fyzickým osobám nepublikujeme adresu.", ) address_street_with_number = models.CharField( max_length=256, blank=True, null=True, verbose_name="Ulice, č.p.", help_text="Veřejné pouze, když typ není nastaven na fyzickou osobu.", ) # WARNING: Legal entity status dependent! address_district = models.CharField( max_length=256, blank=True, null=True, verbose_name="Obec", ) address_zip = models.CharField( max_length=256, blank=True, null=True, verbose_name="PSČ", help_text="Veřejné pouze, když typ není nastaven na fyzickou osobu.", ) # WARNING: Legal entity status dependent! address_country = models.CharField( max_length=256, blank=True, null=True, verbose_name="Země", default=get_default_country, ) ico_number = models.CharField( max_length=256, blank=True, null=True, verbose_name="IČO", help_text=( "U právnických a podnikajících fyzických osob musí být vyplněno. " "Vyplněním můžeš automaticky načíst data z ARES." ), ) # WARNING: Legal entity status dependent! date_of_birth = models.DateField( blank=True, null=True, verbose_name="Datum narození", help_text="U fyzických osob musí být vyplněno.", ) # WARNING: Legal entity status dependent! department = models.CharField( max_length=256, blank=True, null=True, verbose_name="Organizační složka", ) @property def url(self) -> str: return reverse("contracts:view_signee", args=(self.id,)) @property def entity_has_public_address(self) -> bool: return self.entity_type in ( self.EntityTypes.LEGAL_ENTITY, self.EntityTypes.OTHER, ) @property def has_any_address_information(self) -> bool: for field in ( "address_street_with_number", "address_district", "address_zip", "address_country", ): if getattr(self, field): return True return False def clean(self): if ( self.entity_type == self.EntityTypes.NATURAL_PERSON and not self.date_of_birth ): raise ValidationError( {"date_of_birth": "U fyzických osob musí být definováno."} ) if ( self.entity_type in (self.EntityTypes.LEGAL_ENTITY, self.EntityTypes.BUSINESS_NATURAL_PERSON) and not self.ico_number ): raise ValidationError( { "ico_number": "U právnických a podnikajících fyzických osob musí být definováno." } ) if self.entity_type != self.EntityTypes.OTHER: for field in ( "address_street_with_number", "address_district", "address_zip", "address_country", ): if not getattr(self, field): raise ValidationError( {field: 'Pokud není vybrán typ "Jiné", musí být definováno.'} ) return super().clean() def __str__(self) -> str: result = str(self.name) if self.ico_number is not None: result += f" ({self.ico_number})" if self.date_of_birth is not None: result += f" ({self.date_of_birth})" if self.department is not None: result += f", {self.department}" return result class Meta: app_label = "contracts" verbose_name = "Jiná smluvní strana" verbose_name_plural = "Ostatní smluvní strany" ordering = ["name", "department"] permissions = OwnPermissionsMixin.Meta.permissions def get_default_contractee_name() -> str: return settings.DEFAULT_CONTRACTEE_NAME def get_default_contractee_street() -> str: return settings.DEFAULT_CONTRACTEE_STREET def get_default_contractee_district() -> str: return settings.DEFAULT_CONTRACTEE_DISTRICT def get_default_contractee_zip() -> str: return settings.DEFAULT_CONTRACTEE_ZIP def get_default_contractee_ico_number() -> str: return settings.DEFAULT_CONTRACTEE_ICO_NUMBER class Contractee( CreatedByMixin, OwnPermissionsMixin, SignatureCountMixin, InlineNameMixin, models.Model, ): name = models.CharField( max_length=256, default=get_default_contractee_name, verbose_name="Jméno", ) address_street_with_number = models.CharField( max_length=256, default=get_default_contractee_street, verbose_name="Ulice, č.p.", ) address_district = models.CharField( max_length=256, default=get_default_contractee_district, verbose_name="Obec", ) address_zip = models.CharField( max_length=256, default=get_default_contractee_zip, verbose_name="PSČ", ) address_country = models.CharField( max_length=256, verbose_name="Země", default=get_default_country, ) ico_number = models.CharField( max_length=256, blank=True, null=True, default=get_default_contractee_ico_number, verbose_name="IČO", ) department = models.CharField( max_length=256, blank=True, null=True, verbose_name="Organizační složka", ) @property def url(self) -> str: return reverse("contracts:view_contractee", args=(self.id,)) def __str__(self) -> str: result = str(self.name) if self.department is not None: result += f", {self.department}" return result class Meta: app_label = "contracts" verbose_name = "Naše smluvní strana" verbose_name_plural = "Naše smluvní strany" ordering = ["name", "department"] permissions = OwnPermissionsMixin.Meta.permissions class ContractType(ContractCountMixin, NameStrMixin, models.Model): name = models.CharField( max_length=256, verbose_name="Jméno", ) @property def url(self) -> str: return reverse("contracts:view_contract_type", args=(self.id,)) class Meta: app_label = "contracts" ordering = ["name"] verbose_name = "Typ smlouvy" verbose_name_plural = "Typy smluv" class ContractIssue(ContractCountMixin, NameStrMixin, models.Model): name = models.CharField( max_length=256, verbose_name="Jméno", ) @property def url(self) -> str: return reverse("contracts:view_contract_issue", args=(self.id,)) class Meta: app_label = "contracts" ordering = ["name"] verbose_name = "Problém se smlouvou" verbose_name_plural = "Problémy se smlouvami" class ContractFilingArea(ContractCountMixin, NameStrMixin, models.Model): name = models.CharField( max_length=256, verbose_name="Jméno", ) person_responsible = models.CharField( max_length=256, verbose_name="Odpovědná osoba", ) @property def url(self) -> str: return reverse("contracts:view_contract_filing_area", args=(self.id,)) class Meta: app_label = "contracts" ordering = ["name"] verbose_name = "Spisovna" verbose_name_plural = "Spisovny" def get_current_utc_timestamp(): return datetime.datetime.now(datetime.timezone.utc) # Pre-squash migration compatibility get_created_on_timestamp = get_current_utc_timestamp class Contract(NameStrMixin, models.Model): # BEGIN Automatically set fields created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True, related_name="uploaded_contracts", verbose_name="Vytvořena uživatelem", help_text="Pokud vytváříš novou smlouvu, autorem budeš ty.", ) # WARNING: exclude in admin created_on = models.DateTimeField( blank=False, null=False, default=get_current_utc_timestamp, verbose_name="Čas vytvoření", ) updated_on = models.DateTimeField( blank=False, null=False, default=get_current_utc_timestamp, verbose_name="Čas poslední aktualizace", ) public_status_set_by = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, related_name="public_status_altered_contracts", verbose_name="Zveřejněno / nezveřejněno uživatelem", help_text="Obsah není veřejně přístupný.", ) # WARNING: exclude in admin all_parties_sign_date = models.DateField( verbose_name="Datum podpisu všech stran", blank=True, null=True, ) # WARNING: Exclude in admin, autofill # END Automatically set fields # BEGIN Approval fields is_approved = models.BooleanField( verbose_name="Je schválená", default=False, help_text=( "Mohou měnit jen schvalovatelé. Pokud je " "smlouva veřejná, schválením se vypustí ven." ), ) # END Approval fields name = models.CharField( max_length=256, verbose_name="Název", ) id_number = models.CharField( max_length=256, blank=True, null=True, verbose_name="Identifikační číslo", help_text=mark_safe( "<strong>Není IČO!</strong> Používá se pro identifikaci smluv " "s velkými organizacemi. Např. <code>MF-8687/2022/15-3</code>." ), ) types = models.ManyToManyField( ContractType, related_name="contracts", verbose_name="Typ", ) summary = models.TextField( max_length=256, blank=True, null=True, verbose_name="Sumarizace obsahu smlouvy", ) valid_start_date = models.DateField( blank=True, null=True, verbose_name="Začátek účinnosti", help_text=mark_safe( "<strong>Pokud není začátek účinnosti zadán, vypočítá se automaticky " "podle posledního data podpisu.</strong>" ), ) valid_end_date = models.DateField( blank=True, null=True, verbose_name="Konec účinnosti", help_text=mark_safe( "<strong>Pokud není konec účinnosti zadán, smlouva je evidována jako " "na dobu neurčitou.</strong>" ), ) class PublicStates(models.TextChoices): YES = "yes", "Veřejná" NO = "no", "Neveřejná" class PaperFormStates(models.TextChoices): ON_THE_WAY = "on_the_way", "Na cestě" SENT = "sent", "Odeslaný" MAILBOX = "mailbox", "Ve schránce" TO_SHRED = "to_shred", "Ke skartaci" SHREDDED = "shredded", "Skartovaný" STORED = "stored", "Uložený" LOST = "lost", "Ztracený" is_valid = models.BooleanField( default=False, verbose_name="Je právně platná", help_text="Právní vztah vyplývající ze smlouvy je aktuálně účinný a platný", ) is_public = models.BooleanField( default=True, verbose_name="Je veřejná", help_text=( "Neveřejné smlouvy nejsou vidět bez přihlášení jako min. tajný čtenář." ), ) publishing_rejection_comment = models.TextField( max_length=65536, blank=True, null=True, verbose_name="Důvod nezveřejnění", help_text="Obsah není veřejně přístupný.", ) # WARNING: public status dependent paper_form_state = models.CharField( max_length=10, choices=PaperFormStates.choices, verbose_name="Stav fyzického dokumentu", ) paper_form_person_responsible = models.CharField( max_length=256, blank=True, null=True, verbose_name="Osoba zodpovědná za doručení", ) tender_url = SubdomainValidatedURLField( max_length=256, blank=True, null=True, verbose_name="Odkaz na výběrové řízení", help_text=mark_safe( 'Běžně odkaz na <a href="https://forum.pirati.cz/viewforum.php?f=572">VŘ sekci</a> ' "fóra. Musí začínat <i>https</i> a být pod doménou <i>pirati.cz</i>." ), ) issues = models.ManyToManyField( ContractIssue, blank=True, related_name="contracts", verbose_name="Problémy", help_text='Veřejně nazváno "Poznámky".', ) class CostUnits(models.TextChoices): HOUR = "hour", "Hodina" MONTH = "month", "Měsíc" YEAR = "year", "Rok" TOTAL = "total", "Celkem" OTHER = "other", "Jiné" cost_amount = models.PositiveIntegerField( blank=True, null=True, verbose_name="Náklady (Kč)", help_text="Pokud se smlouvu nejsou spojené náklady, nevyplňuj vůbec.", ) cost_unit = models.CharField( max_length=5, choices=CostUnits.choices, blank=True, null=True, verbose_name="Jednotka nákladů", ) cost_unit_other = models.CharField( max_length=256, verbose_name="Jednotka nákladů (jiné)", help_text='Je nutno vyplnit v případě, že máš vybranou možnost "jiné" v jednotce nákladů.', blank=True, null=True, ) filing_area = models.ForeignKey( ContractFilingArea, on_delete=models.SET_NULL, blank=True, null=True, related_name="contracts", verbose_name="Spisovna", help_text="Obsah není veřejně přístupný.", ) primary_contract = models.ForeignKey( "Contract", on_delete=models.SET_NULL, # TODO: Figure out if we want this behavior blank=True, null=True, related_name="subcontracts", verbose_name="Primární smlouva", help_text="Např. pro dodatky nebo objednávky u rámcových smluv.", ) # WARNING: Dependent on the type! notes = MarkdownxField( blank=True, null=True, verbose_name="Poznámky", help_text="Poznámky jsou viditelné pro všechny, kteří mohou smlouvu spravovat a pro tajné čtenáře.", ) @property def primary_contract_url(self) -> typing.Union[None, str]: if self.primary_contract is None: return return reverse( "contracts:view_contract", args=(self.primary_contract.id,), ) @property def url(self) -> str: return reverse( "contracts:view_contract", args=(self.id,), ) def get_public_files(self): return ContractFile.objects.filter( contract=self, is_public=True, ).all() def get_private_files(self): return ContractFile.objects.filter( contract=self, is_public=False, ).all() def calculate_signing_parties_sign_date(self) -> None: contractees_latest_sign_date = None signees_latest_sign_date = None for contractee_signature in self.contractee_signatures.all(): if ( contractees_latest_sign_date is None or contractee_signature.date > contractees_latest_sign_date ): contractees_latest_sign_date = contractee_signature.date for signee_signature in self.signee_signatures.all(): if ( signees_latest_sign_date is None or signee_signature.date > signees_latest_sign_date ): signees_latest_sign_date = signee_signature.date if ( contractees_latest_sign_date is not None and signees_latest_sign_date is not None ): self.all_parties_sign_date = max( (contractees_latest_sign_date, signees_latest_sign_date) ) self.save() def clean(self): if not self.is_public and not self.publishing_rejection_comment: raise ValidationError( { "publishing_rejection_comment": "Pokud smlouva není veřejná, toto pole musí být vyplněné." } ) elif self.is_public and self.publishing_rejection_comment: raise ValidationError( { "publishing_rejection_comment": "Nemůže být definováno, pokud je smlouva veřejná." } ) if ( self.paper_form_state not in ( self.PaperFormStates.STORED, self.PaperFormStates.SHREDDED, self.PaperFormStates.LOST ) and not self.paper_form_person_responsible ): raise ValidationError( { "paper_form_person_responsible": ( "Musí být vyplněno, pokud smlouva není uložená / skartovaná / ztracená." ) } ) if ( self.valid_start_date and self.valid_end_date and self.valid_start_date > self.valid_end_date ): raise ValidationError( { "valid_end_date": "Konec platnosti nemůže být definován dříve, než začátek." } ) if self.cost_amount is None and self.cost_unit: # 0 is falsy, but a value raise ValidationError( {"cost_unit": "Nemůže být definováno, pokud nejsou zadány náklady."} ) if ( self.cost_amount is not None # 0 is falsy, but a value and not self.cost_unit ): raise ValidationError( {"cost_amount": "Nemůže být definováno bez jednoty nákladů."} ) if self.cost_unit == self.CostUnits.OTHER and not self.cost_unit_other: raise ValidationError( { "cost_unit_other": 'Musí být definováno, pokud je vybrána jednotka nákladů "jiné".' } ) elif self.cost_unit != self.CostUnits.OTHER and self.cost_unit_other: raise ValidationError( { "cost_unit_other": 'Nemůže být definováno, pokud není vybrána jednotka nákladů "jiné".' } ) if ( self.primary_contract is not None and self.is_public and not self.primary_contract.is_public ): raise ValidationError( { "is_public": "Primární smlouva je neveřejná, tato smlouva nemůže být veřejná." } ) return super().clean() def save(self, *args, **kwargs): self.updated_on = get_current_utc_timestamp() return super().save(*args, **kwargs) # Settings settings = app_settings.ContractSettings("Nastavení") class Meta: app_label = "contracts" verbose_name = "Smlouva" verbose_name_plural = "Smlouvy" ordering = ( "-created_on", "-updated_on", "name", ) permissions = [ ("approve", "Schválit / zrušit schválení"), ("view_confidential", "Zobrazit tajné informace"), ("edit_when_approved", "Upravit schválené"), ("delete_when_approved", "Odstranit schválené"), ] + OwnPermissionsMixin.Meta.permissions def get_contract_file_loaction(instance, filename): mimetypes_instance = mimetypes.MimeTypes() current_time = datetime.datetime.today() guessed_type = mimetypes_instance.guess_type(filename, strict=False)[0] extension = "" if guessed_type is not None: for mapper in mimetypes_instance.types_map_inv: if guessed_type not in mapper: continue extension = mapper[guessed_type] if isinstance(extension, list): extension = extension[0] break return ( "_private/" f"{current_time.year}/{current_time.month}/{current_time.day}/" f"{uuid.uuid4()}{extension}" ) class ContractFileProxy(FieldFile): @property def url(self) -> str: return reverse( "contracts:download_contract_file", args=(str(self.instance.id),) ) class ContractFileField(models.FileField): attr_class = ContractFileProxy class ContractFile(NameStrMixin, models.Model): name = models.CharField( max_length=256, verbose_name="Jméno", ) is_public = models.BooleanField( verbose_name="Veřejně dostupný", ) file = ContractFileField( verbose_name="Soubor", upload_to=get_contract_file_loaction, ) contract = models.ForeignKey( Contract, on_delete=models.CASCADE, related_name="files", verbose_name="Soubory", ) @property def protected_url(self) -> str: return reverse( "contracts:download_contract_file", args=(self.id,), ) class Meta: app_label = "contracts" verbose_name = "Soubor" verbose_name_plural = "Soubory" class ContracteeSignature(models.Model): contractee = models.ForeignKey( Contractee, on_delete=models.CASCADE, related_name="signatures", verbose_name="Smluvní strana", ) contract = models.ForeignKey( Contract, on_delete=models.CASCADE, related_name="contractee_signatures", verbose_name="Podpisy našich smluvních stran", ) date = models.DateField( verbose_name="Datum podpisu", ) role = models.CharField( max_length=256, blank=True, null=True, verbose_name="Role", help_text='Např. "nájemník"', ) def __str__(self) -> str: return f"{str(self.contractee)} - {self.date}" class Meta: app_label = "contracts" verbose_name = "Podpis naší smluvní strany" verbose_name_plural = "Podpisy našich smluvních stran" class SigneeSignature(models.Model): signee = models.ForeignKey( Signee, on_delete=models.CASCADE, related_name="signatures", verbose_name="Smluvní strana", ) contract = models.ForeignKey( Contract, on_delete=models.CASCADE, related_name="signee_signatures", verbose_name="Podpisy jiných smluvních stran", ) date = models.DateField( verbose_name="Datum podpisu", ) role = models.CharField( max_length=256, blank=True, null=True, verbose_name="Role", help_text='Např. "pronajímatel"', ) def __str__(self) -> str: return f"{str(self.signee)} - {self.date}" class Meta: app_label = "contracts" verbose_name = "Podpis jiné smluvní strany" verbose_name_plural = "Podpisy jiných smluvních stran" class ContracteeSignatureRepresentative(RepresentativeMixin, models.Model): signature = models.ForeignKey( ContracteeSignature, on_delete=models.CASCADE, related_name="representatives", verbose_name="Podpis naši smluvní strany", ) name = models.CharField( max_length=256, verbose_name="Jméno", ) function = models.CharField( max_length=256, verbose_name="Funkce", ) class Meta: app_label = "contracts" verbose_name = "Zástupce naší smluvní strany" verbose_name_plural = "Zástupci naší smluvní strany" class SigneeSignatureRepresentative(RepresentativeMixin, models.Model): signature = models.ForeignKey( SigneeSignature, on_delete=models.CASCADE, related_name="representatives", verbose_name="Podpis jiné smluvní strany", ) name = models.CharField( max_length=256, verbose_name="Jméno", ) function = models.CharField( max_length=256, blank=True, null=True, verbose_name="Funkce", ) class Meta: app_label = "contracts" verbose_name = "Zástupce jiné smluvní strany" verbose_name_plural = "Zástupci jiné smluvní strany" @receiver(post_save, sender=ContracteeSignature) @receiver(post_save, sender=SigneeSignature) def signing_parties_post_save_update_dates(sender, instance, *args, **kwargs) -> None: instance.contract.calculate_signing_parties_sign_date() class ContractApproval(NameStrMixin, models.Model): name = models.CharField( max_length=256, verbose_name="Jméno", ) url = SubdomainValidatedURLField( max_length=256, verbose_name="Odkaz", help_text=mark_safe( "Využívá se např. u koaličních smluv. Musí začínat <i>https</i> " "a být pod doménou <i>pirati.cz</i>." ), ) contract = models.ForeignKey( Contract, on_delete=models.CASCADE, related_name="approvals", verbose_name="Smlouva", ) class Meta: app_label = "contracts" verbose_name = "Odkaz na schválení na Fóru" verbose_name_plural = "Odkazy na schválení na Fóru" class ContractIntent(NameStrMixin, models.Model): name = models.CharField( max_length=256, verbose_name="Jméno", ) url = SubdomainValidatedURLField( max_length=256, verbose_name="Odkaz", help_text=mark_safe( "Musí začínat <i>https</i> a být pod doménou <i>pirati.cz</i>." ), ) contract = models.ForeignKey( Contract, on_delete=models.CASCADE, related_name="intents", verbose_name="Smlouva", ) class Meta: app_label = "contracts" verbose_name = "Odkaz na záměr v Piroplácení" verbose_name_plural = "Odkazy na záměry v Piroplácení"