From e1cdf4a8a47f3d1315ef89801e24b7b1044750cc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1?= <git@imaniti.org>
Date: Sat, 4 Feb 2023 00:15:49 +0900
Subject: [PATCH] update db schema

---
 README.md                                     | 153 +++++++++++++---
 registry/settings/production.py               |   9 +
 requirements/production.txt                   |   2 +
 shared/migrations/0001_initial.py             | 171 ++++++++++++++----
 ...contractlocalsigner_department_and_more.py |  78 --------
 shared/models.py                              |  75 +++++---
 6 files changed, 325 insertions(+), 163 deletions(-)
 delete mode 100644 shared/migrations/0002_contractlocalsigner_department_and_more.py

diff --git a/README.md b/README.md
index 93b1e17..e0f2388 100644
--- a/README.md
+++ b/README.md
@@ -1,39 +1,150 @@
-# Contract registry
+# Registr smluv
 
-The Czech Pirate Party's transparent evidence system for its contracts and
-information about them.
+Aplikace pro transparentní evidenci smluv a informací s nimi spojených.
 
-## Basic requirements
-- `make`
-- `python`, 3.9 or newer
-- `python-virtualenv`
-- A PostgreSQL server
+## Struktura projektu
+```
+.
+├── registry                = Nastavení projektu, URLs.
+├── shared                  = Sdílené modely, templaty a statické soubory pro všechny ostatní aplikace.
+├── static_src              = Zdrojové CSS a JS, které příkazem 'make build' buildujeme.
+├── requirements            = Pythonové závislosti z PyPI.
+└── env.example             = Příklad .env souboru.
+```
+
+## Konfigurace
+
+Je třeba definovat minimálně následující environment proměnné:
+| proměnná | popis |
+| --- | --- |
+| `DATABASE_URL` | URL pro připojení k databázi ve formátu `postgresql://username:password@host:5432/database_name` |
+| `SECRET_KEY` | Tajný klíč např. pro šifrování |
+| `DEFAULT_LOCAL_SIGNER_NAME` | Defaultní jméno naší podepisující strany |
+| `DEFAULT_LCOAL_SIGNER_STREET` | Defaultní ulice a č.p. naší podepisující strany |
+| `DEFAULT_LOCAL_SIGNER_ZIP` | Defaultní PSČ naší podepisující strany |
+| `DEFAULT_LOCAL_SIGNER_DISTRICT` | Defaultní obec naší podepisující strany |
+| `DEFAULT_LOCAL_SIGNER_COUNTRY` | Defaultní země naší podepisující strany, např. `CZ`, `DE` |
+| `DEFAULT_LOCAL_SIGNER_ICO_NUMBER` | Defaultní IČO naší podepisující strany |
+
+V produkci je potřeba:
+| proměnná | popis |
+| --- | --- |
+| `ALLOWED_HOSTS` | Seznam domén, skrz které se na server lze připojovat. [Více info](https://docs.djangoproject.com/en/4.1/ref/settings/#allowed-hosts) |
+
+## Vývoj
+
+V produkci používáme Docker. Při vývoji se hodí přiložený `Makefile`, pro automatizování často prováděných akcí. Pro nápovědu zavolej:
 
-## Setup
+```bash
+$ make help
+```
 
-Copy `env.example` to `.env`.
+### Lokální setup
 
-Set the ``DATABASE_URL`` environment variable in `.env` to one you can access your database server with. The format should be  as per RFC 1738, such as `postgresql://login:password@localhost:5432/database_name`. It's also important to change the ``SECRET_KEY`` variable.
+Požadavky:
+- Python 3.9+
+- Linuxové prostředí, na Windows netestováno
 
-Then, run the following commands:
+Zkopíruj `env.example` do `.env`, nastav potřebné proměnné.
+
+Vytvoř virtualenv:
 
 ```bash
-make venv     # Create virtual environment
-make install  # Install Python and Node.js dependencies
-make build    # Build static files
+$ make venv
 ```
 
-## Running
+Vytvoří virtualenv ve složce `.venv`. Předpokládá že výchozí `python` v terminálu
+je Python 3. Pokud tomu tak není, použij třeba [Pyenv](https://github.com/pyenv/pyenv)
+pro instalaci více verzí Pythonu bez rizika rozbití systému.
+
+### Aktivace virtualenvu
 
-To run with the default settings on the port set in `Makefile`, run:
+Před prací na projektu je třeba aktivovat virtualenv. To bohužel nejde dělat
+pomocí nástroje `make`. Je třeba zavolat příkaz:
 
 ```bash
-make run
+$ source .venv/bin/activate
 ```
 
-For more customization, it's better practice to run the server starting command directly:
+Pro shell lze vytvořit alias. Do `~/.bash_profile`, `~/.zshrc` nebo jiného
+konfiguračního souboru dle tvého shellu přidej:
+
+```bash
+$ alias senv='source .venv/bin/activate'
+```
+
+A pak můžeš virtualenv aktivovat pouze jednoduchým voláním:
+
+```bash
+$ senv
+```
+
+Pro sofistikovanější řešení, které aktivuje virtualenv při změně adresáře na
+adresář s projektem, slouží nástroj [direnv](https://direnv.net/).
+
+Deaktivace virtualenv se dělá příkazem:
+
+```bash
+$ deactivate
+```
+
+### Instalace závislostí
+
+Spusť:
+
+```bash
+$ make install
+```
+
+Tím se nainstalují Pythonové závislosti a virtuální Node.js 19x nutné k buildu statických souborů.
+
+### Build statických souborů
+
+Spusť:
+
+```bash
+$ make build
+```
+
+Tím se vytvoří CSS a JS soubory, které se dají použít v prohlížečovém prostředí.
+
+### Spuštění development serveru
+
+Django development server na portu `8012` se spustí příkazem:
+
+```bash
+$ make run
+```
+
+Poté můžeš web otevřít na adrese [http://localhost:8012](http://localhost:8012).
+
+### Code quality
+
+K formátování kódu se používá [black](https://github.com/psf/black). Doporučujeme
+ho nainstalovat do tvého editoru, aby soubory přeformátoval po uložení.
+
+Přeformátování kódu nástrojem `black` je součástí `pre-commit` hooks (viz níže).
+
+Součástí `pre-commit` hooků je také automatické seřazení importů v Pythonních
+souborech nástrojem [isort](https://github.com/timothycrosley/isort/).
+
+### Pre-commit hooky
+
+Použivá se [pre-commit](https://pre-commit.com/) framework pro management git
+pre-commit hooks.
+
+Máš-li pre-commit framework [nainstalovaný](https://pre-commit.com/#installation),
+spusť příkaz:
+
+```bash
+$ make install-hooks
+```
+
+Ten naisntaluje hooky pro projekt. A poté při každém commitu dojde k požadovaným
+akcím na změněných souborech.
+
+Ručně se dají hooky na všechny soubory spustit příkazem:
 
 ```bash
-source .venv/bin/activate   # Load the virtual environment
-python manage.py runserver  # [ Your settings here ]
+$ make hooks
 ```
diff --git a/registry/settings/production.py b/registry/settings/production.py
index 9b5ed21..d0c9451 100644
--- a/registry/settings/production.py
+++ b/registry/settings/production.py
@@ -1 +1,10 @@
+"""
+Production settings.
+"""
+
 from .base import *
+
+ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
+
+MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware")
+STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
diff --git a/requirements/production.txt b/requirements/production.txt
index e69de29..ce5169e 100644
--- a/requirements/production.txt
+++ b/requirements/production.txt
@@ -0,0 +1,2 @@
+gunicorn==20.1.0
+whitenoise==6.3.0
diff --git a/shared/migrations/0001_initial.py b/shared/migrations/0001_initial.py
index 0d45e6a..c0a4def 100644
--- a/shared/migrations/0001_initial.py
+++ b/shared/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 4.1.4 on 2023-02-03 04:50
+# Generated by Django 4.1.4 on 2023-02-03 15:13
 
 import django.db.models.deletion
 import django.utils.timezone
@@ -145,18 +145,6 @@ class Migration(migrations.Migration):
                     "contains_nda",
                     models.BooleanField(default=False, verbose_name="Obsahuje NDA"),
                 ),
-                (
-                    "is_anonymized",
-                    models.BooleanField(default=False, verbose_name="Je anonymizovaná"),
-                ),
-                (
-                    "external_signer_signature_date",
-                    models.DateField(verbose_name="Datum podpisu druhé strany"),
-                ),
-                (
-                    "local_signer_signature_date",
-                    models.DateField(verbose_name="Datum podpisu naší strany"),
-                ),
                 (
                     "all_parties_sign_date",
                     models.DateField(verbose_name="Datum podpisu všech stran"),
@@ -165,7 +153,7 @@ class Migration(migrations.Migration):
                     "valid_start_date",
                     models.DateField(verbose_name="Začátek účinnosti"),
                 ),
-                ("valid_end_date", models.DateField(verbose_name="Začátek platnosti")),
+                ("valid_end_date", models.DateField(verbose_name="Konec platnosti")),
                 (
                     "legal_state",
                     models.CharField(
@@ -208,6 +196,7 @@ class Migration(migrations.Migration):
                     "publishing_rejection_comment",
                     models.CharField(
                         blank=True,
+                        help_text="Obsah není veřejně přístupný.",
                         max_length=65536,
                         null=True,
                         verbose_name="Důvod nezveřejnění",
@@ -232,14 +221,25 @@ class Migration(migrations.Migration):
                     "summary",
                     models.CharField(
                         blank=True,
+                        help_text="Obsah není veřejně přístupný.",
                         max_length=65536,
                         null=True,
                         verbose_name="Rekapitulace",
                     ),
                 ),
                 (
-                    "contract_file",
-                    models.FileField(upload_to="", verbose_name="Smlouva (PDF)"),
+                    "anonymized_contract_file",
+                    models.FileField(
+                        upload_to="", verbose_name="Anonymizovaná smlouva (PDF)"
+                    ),
+                ),
+                (
+                    "original_contract_file",
+                    models.FileField(
+                        help_text="Obsah není veřejně přístupný.",
+                        upload_to="",
+                        verbose_name="Originální verze smlouvy (PDF)",
+                    ),
                 ),
                 (
                     "expected_cost_total",
@@ -296,17 +296,31 @@ class Migration(migrations.Migration):
                 ("name", models.CharField(max_length=256, verbose_name="Jméno")),
                 (
                     "is_legal_entity",
-                    models.BooleanField(verbose_name="Je právnická osoba"),
+                    models.BooleanField(
+                        help_text="Důležité označit správně! Pokud není osoba právnická, zveřejňujeme pouze obec a zemi.",
+                        verbose_name="Je právnická osoba",
+                    ),
                 ),
                 (
                     "address_street_with_number",
-                    models.CharField(max_length=256, verbose_name="Ulice, č.p."),
+                    models.CharField(
+                        help_text="Viditelné pouze u právnických osob.",
+                        max_length=256,
+                        verbose_name="Ulice, č.p.",
+                    ),
                 ),
                 (
                     "address_district",
                     models.CharField(max_length=256, verbose_name="Obec"),
                 ),
-                ("address_zip", models.CharField(max_length=16, verbose_name="PSČ")),
+                (
+                    "address_zip",
+                    models.CharField(
+                        help_text="Viditelné pouze u právnických osob.",
+                        max_length=16,
+                        verbose_name="PSČ",
+                    ),
+                ),
                 (
                     "address_country",
                     django_countries.fields.CountryField(
@@ -409,26 +423,72 @@ class Migration(migrations.Migration):
                         verbose_name="ID",
                     ),
                 ),
-                ("name", models.CharField(max_length=256, verbose_name="Jméno")),
+                (
+                    "name",
+                    models.CharField(
+                        default="Česká pirátská strana",
+                        max_length=256,
+                        verbose_name="Jméno",
+                    ),
+                ),
                 (
                     "address_street_with_number",
-                    models.CharField(max_length=256, verbose_name="Ulice, č.p."),
+                    models.CharField(
+                        default="Na Moráni 360/3",
+                        max_length=256,
+                        verbose_name="Ulice, č.p.",
+                    ),
                 ),
                 (
                     "address_district",
-                    models.CharField(max_length=256, verbose_name="Obec"),
+                    models.CharField(
+                        default="Praha 2", max_length=256, verbose_name="Obec"
+                    ),
+                ),
+                (
+                    "address_zip",
+                    models.CharField(
+                        default="128 00", max_length=16, verbose_name="PSČ"
+                    ),
                 ),
-                ("address_zip", models.CharField(max_length=16, verbose_name="PSČ")),
                 (
                     "address_country",
                     django_countries.fields.CountryField(
-                        max_length=2, verbose_name="Země"
+                        default="CZ", max_length=2, verbose_name="Země"
                     ),
                 ),
                 (
                     "ico_number",
                     models.CharField(
-                        blank=True, max_length=16, null=True, verbose_name="IČO"
+                        blank=True,
+                        default="71339698",
+                        max_length=16,
+                        null=True,
+                        verbose_name="IČO",
+                    ),
+                ),
+                (
+                    "representative_name",
+                    models.CharField(
+                        blank=True, max_length=256, null=True, verbose_name="Zástupce"
+                    ),
+                ),
+                (
+                    "representative_role",
+                    models.CharField(
+                        blank=True,
+                        max_length=256,
+                        null=True,
+                        verbose_name="Funkce zástupce",
+                    ),
+                ),
+                (
+                    "department",
+                    models.CharField(
+                        blank=True,
+                        max_length=128,
+                        null=True,
+                        verbose_name="Organizační složka",
                     ),
                 ),
                 ("color", models.CharField(max_length=6, verbose_name="Barva")),
@@ -493,19 +553,61 @@ class Migration(migrations.Migration):
                 "verbose_name_plural": "Poznámky ke smlouvě",
             },
         ),
+        migrations.CreateModel(
+            name="ContractLocalSignature",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("date", models.DateField(verbose_name="Datum podpisu")),
+                (
+                    "signer",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="shared.contractlocalsigner",
+                    ),
+                ),
+            ],
+        ),
+        migrations.CreateModel(
+            name="ContractExternalSignature",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("date", models.DateField(verbose_name="Datum podpisu")),
+                (
+                    "signer",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="shared.contractexternalsigner",
+                    ),
+                ),
+            ],
+        ),
         migrations.AddField(
             model_name="contract",
-            name="external_signer",
-            field=models.ForeignKey(
-                on_delete=django.db.models.deletion.CASCADE,
-                to="shared.contractexternalsigner",
-            ),
+            name="external_signature",
+            field=models.ManyToManyField(to="shared.contractexternalsignature"),
         ),
         migrations.AddField(
             model_name="contract",
             name="filing_area",
             field=models.ForeignKey(
                 blank=True,
+                help_text="Obsah není veřejně přístupný.",
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,
                 to="shared.contractfilingarea",
@@ -518,11 +620,8 @@ class Migration(migrations.Migration):
         ),
         migrations.AddField(
             model_name="contract",
-            name="local_signer",
-            field=models.ForeignKey(
-                on_delete=django.db.models.deletion.CASCADE,
-                to="shared.contractlocalsigner",
-            ),
+            name="local_signature",
+            field=models.ManyToManyField(to="shared.contractlocalsignature"),
         ),
         migrations.AddField(
             model_name="contract",
@@ -539,6 +638,7 @@ class Migration(migrations.Migration):
             model_name="contract",
             name="public_status_set_by",
             field=models.ForeignKey(
+                help_text="Obsah není veřejně přístupný.",
                 on_delete=django.db.models.deletion.CASCADE,
                 related_name="public_status_altered_contracts",
                 to=settings.AUTH_USER_MODEL,
@@ -558,6 +658,7 @@ class Migration(migrations.Migration):
             model_name="contract",
             name="uploaded_by",
             field=models.ForeignKey(
+                help_text="Informace není veřejně přístupná.",
                 on_delete=django.db.models.deletion.CASCADE,
                 related_name="uploaded_contracts",
                 to=settings.AUTH_USER_MODEL,
diff --git a/shared/migrations/0002_contractlocalsigner_department_and_more.py b/shared/migrations/0002_contractlocalsigner_department_and_more.py
deleted file mode 100644
index 010d1a2..0000000
--- a/shared/migrations/0002_contractlocalsigner_department_and_more.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# Generated by Django 4.1.4 on 2023-02-03 05:01
-
-import django_countries.fields
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ("shared", "0001_initial"),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name="contractlocalsigner",
-            name="department",
-            field=models.CharField(
-                blank=True, max_length=128, null=True, verbose_name="Organizační složka"
-            ),
-        ),
-        migrations.AddField(
-            model_name="contractlocalsigner",
-            name="representative_name",
-            field=models.CharField(
-                blank=True, max_length=256, null=True, verbose_name="Zástupce"
-            ),
-        ),
-        migrations.AddField(
-            model_name="contractlocalsigner",
-            name="representative_role",
-            field=models.CharField(
-                blank=True, max_length=256, null=True, verbose_name="Funkce zástupce"
-            ),
-        ),
-        migrations.AlterField(
-            model_name="contractlocalsigner",
-            name="address_country",
-            field=django_countries.fields.CountryField(
-                default="CZ", max_length=2, verbose_name="Země"
-            ),
-        ),
-        migrations.AlterField(
-            model_name="contractlocalsigner",
-            name="address_district",
-            field=models.CharField(
-                default="Praha 2", max_length=256, verbose_name="Obec"
-            ),
-        ),
-        migrations.AlterField(
-            model_name="contractlocalsigner",
-            name="address_street_with_number",
-            field=models.CharField(
-                default="Na Moráni 360/3", max_length=256, verbose_name="Ulice, č.p."
-            ),
-        ),
-        migrations.AlterField(
-            model_name="contractlocalsigner",
-            name="address_zip",
-            field=models.CharField(default="128 00", max_length=16, verbose_name="PSČ"),
-        ),
-        migrations.AlterField(
-            model_name="contractlocalsigner",
-            name="ico_number",
-            field=models.CharField(
-                blank=True,
-                default="71339698",
-                max_length=16,
-                null=True,
-                verbose_name="IČO",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="contractlocalsigner",
-            name="name",
-            field=models.CharField(
-                default="Česká pirátská strana", max_length=256, verbose_name="Jméno"
-            ),
-        ),
-    ]
diff --git a/shared/models.py b/shared/models.py
index 6e90b49..e48deae 100644
--- a/shared/models.py
+++ b/shared/models.py
@@ -17,11 +17,13 @@ class ContractExternalSigner(models.Model):
 
     is_legal_entity = models.BooleanField(
         verbose_name="Je právnická osoba",
+        help_text="Důležité označit správně! Pokud není osoba právnická, zveřejňujeme pouze obec a zemi.",
     )
 
     address_street_with_number = models.CharField(
         max_length=256,
         verbose_name="Ulice, č.p.",
+        help_text="Viditelné pouze u právnických osob.",
     )  # WARNING: Legal entity status dependent!
 
     address_district = models.CharField(
@@ -32,6 +34,7 @@ class ContractExternalSigner(models.Model):
     address_zip = models.CharField(
         max_length=16,
         verbose_name="PSČ",
+        help_text="Viditelné pouze u právnických osob.",
     )  # WARNING: Legal entity status dependent!
 
     address_country = CountryField(
@@ -77,6 +80,17 @@ class ContractExternalSigner(models.Model):
         verbose_name_plural = "Druhé smluvní strany"
 
 
+class ContractExternalSignature(models.Model):
+    signer = models.ForeignKey(
+        ContractExternalSigner,
+        on_delete=models.CASCADE,
+    )
+
+    date = models.DateField(
+        verbose_name="Datum podpisu",
+    )
+
+
 class ContractLocalSigner(models.Model):
     name = models.CharField(
         max_length=256,
@@ -147,6 +161,17 @@ class ContractLocalSigner(models.Model):
         verbose_name_plural = "Naše smlouvní strany"
 
 
+class ContractLocalSignature(models.Model):
+    signer = models.ForeignKey(
+        ContractLocalSigner,
+        on_delete=models.CASCADE,
+    )
+
+    date = models.DateField(
+        verbose_name="Datum podpisu",
+    )
+
+
 class ContractSubtype(models.Model):
     name = models.CharField(
         max_length=32,
@@ -209,37 +234,19 @@ class Contract(models.Model):
         verbose_name="Obsahuje NDA",
     )
 
-    is_anonymized = models.BooleanField(
-        default=False,
-        verbose_name="Je anonymizovaná",
-    )  # WARNING: Seems to only be used for amendments
-
-    external_signer = models.ForeignKey(
-        ContractExternalSigner,
-        on_delete=models.CASCADE,
-    )
-
-    # NOTE: Should we allow these to be null, if a contract is logged before it is signed?
-    external_signer_signature_date = models.DateField(
-        verbose_name="Datum podpisu druhé strany",
-    )
-
-    local_signer = models.ForeignKey(
-        ContractLocalSigner,
-        on_delete=models.CASCADE,
-    )
+    external_signature = models.ManyToManyField(ContractExternalSignature)
 
-    local_signer_signature_date = models.DateField(
-        verbose_name="Datum podpisu naší strany",
-    )
+    local_signature = models.ManyToManyField(ContractLocalSignature)
 
     all_parties_sign_date = models.DateField(
         verbose_name="Datum podpisu všech stran",
     )  # WARNING: Exclude in admin, autofill
 
-    valid_start_date = models.DateField(verbose_name="Začátek účinnosti")
+    valid_start_date = models.DateField(
+        verbose_name="Začátek účinnosti",
+    )
     valid_end_date = models.DateField(
-        verbose_name="Začátek platnosti",
+        verbose_name="Konec platnosti",
     )
 
     uploaded_by = models.ForeignKey(
@@ -247,7 +254,8 @@ class Contract(models.Model):
         on_delete=models.CASCADE,
         related_name="uploaded_contracts",
         verbose_name="Nahráno uživatelem",
-    )
+        help_text="Informace není veřejně přístupná.",
+    )  # WARNING: exclude in admin
 
     class LegalStates(models.TextChoices):
         VALID = "valid", "Platná"
@@ -289,14 +297,16 @@ class Contract(models.Model):
         on_delete=models.CASCADE,
         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
 
     publishing_rejection_comment = models.CharField(
         max_length=65536,
         blank=True,
         null=True,
         verbose_name="Důvod nezveřejnění",
-    )
+        help_text="Obsah není veřejně přístupný.",
+    )  # WARNING: exclude in admin
 
     tender_url = models.URLField(
         max_length=256,
@@ -317,10 +327,16 @@ class Contract(models.Model):
         blank=True,
         null=True,
         verbose_name="Rekapitulace",
+        help_text="Obsah není veřejně přístupný.",
+    )
+
+    anonymized_contract_file = models.FileField(
+        verbose_name="Anonymizovaná smlouva (PDF)",
     )
 
-    contract_file = models.FileField(
-        verbose_name="Smlouva (PDF)",
+    original_contract_file = models.FileField(
+        verbose_name="Originální verze smlouvy (PDF)",
+        help_text="Obsah není veřejně přístupný.",
     )
 
     primary_contract = models.ForeignKey(
@@ -358,6 +374,7 @@ class Contract(models.Model):
         on_delete=models.CASCADE,
         blank=True,
         null=True,
+        help_text="Obsah není veřejně přístupný.",
     )  # WARNING: Dependent on the type!
 
     class Meta:
-- 
GitLab