import datetime import mimetypes import os import typing 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.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 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 = 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=16, 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=16, 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=128, 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 = 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" 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=16, 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=16, blank=True, null=True, default=get_default_contractee_ico_number, verbose_name="IČO", ) department = models.CharField( max_length=128, 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 = 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" permissions = OwnPermissionsMixin.Meta.permissions class ContractType(ContractCountMixin, NameStrMixin, models.Model): name = models.CharField( max_length=32, verbose_name="Jméno", ) @property def url(self) -> str: return reverse("contracts:view_contract_type", args=(self.id,)) class Meta: app_label = "contracts" verbose_name = "Typ smlouvy" verbose_name_plural = "Typy smluv" class ContractIssue(ContractCountMixin, NameStrMixin, models.Model): name = models.CharField( max_length=32, verbose_name="Jméno", ) @property def url(self) -> str: return reverse("contracts:view_contract_issue", args=(self.id,)) class Meta: app_label = "contracts" verbose_name = "Problém se smlouvou" verbose_name_plural = "Problémy se smlouvami" class ContractFilingArea(ContractCountMixin, NameStrMixin, models.Model): name = models.CharField( max_length=32, 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" 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=128, verbose_name="Název", ) id_number = models.CharField( max_length=128, blank=True, null=True, verbose_name="Identifikační číslo", ) 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 LegalStates(models.TextChoices): VALID = "valid", "Platná" INVALID = "invalid", "Neplatná" 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ý" legal_state = models.CharField( max_length=13, choices=LegalStates.choices, verbose_name="Stav právního ujednání", ) 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", ) 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>." ), ) agreement_url = SubdomainValidatedURLField( max_length=256, blank=True, null=True, verbose_name="Odkaz na schválení", help_text=mark_safe( "Běžně odkaz na fórum. 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>." ), ) # WARNING: Dependent on the type! 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=128, 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.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) class Meta: app_label = "contracts" verbose_name = "Smlouva" verbose_name_plural = "Smlouvy" 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] break return ( "_private/" f"{current_time.year}/{current_time.month}/{current_time.day}/" f"{str(instance.id)}{extension}" ) class ContractFile(NameStrMixin, models.Model): name = models.CharField( max_length=128, blank=True, null=True, verbose_name="Jméno", ) is_public = models.BooleanField( verbose_name="Veřejně dostupný", ) file = models.FileField( 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): contractee_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): signee_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 ContractIntent(NameStrMixin, models.Model): name = models.CharField( max_length=128, 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 = "Záměr v Piroplácení" verbose_name_plural = "Záměry v Piroplácení"