Version alpha groups auto assign #640

Closed
lehmann wants to merge 4 commits from (deleted):master into master
12 changed files with 498 additions and 109 deletions

View File

@ -0,0 +1,87 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Génération bulletin BUT synthétique en une page
On génère du HTML. Il sera si possible traduit en PDF par weasyprint.
Le HTML est lui même généré à partir d'un template Jinja.
## Données
Ces données sont des objets passés au template.
- `etud: Identite` : l'étudiant
- `formsemestre: FormSemestre` : le formsemestre d'où est émis ce bulletin
- `bulletins_sem: BulletinBUT` les données bulletins pour tous les étudiants
- `bul: dict` : le bulletin (dict, même structure que le json publié)
- `cursus: EtudCursusBUT`: infos sur le cursus BUT (niveaux validés etc)
- `decision_ues: dict`: `{ acronyme_ue : { 'code' : 'ADM' }}` accès aux décisions
de jury d'UE
- `ects_total` : nombre d'ECTS validées dans ce cursus
- `ue_validation_by_niveau : dict` : les validations d'UE de chaque niveau du cursus
"""
import datetime
import time
from flask import render_template, url_for
from flask import g, request
from app.but.bulletin_but import BulletinBUT
from app.but import cursus_but, validations_view
from app.decorators import (
scodoc,
permission_required,
)
from app.models import FormSemestre, FormSemestreInscription, Identite
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
from app.scodoc.sco_logos import find_logo
from app.scodoc.sco_permissions import Permission
from app.views import notes_bp as bp
from app.views import ScoData
@bp.route("/bulletin_but/<int:formsemestre_id>/<int:etudid>")
@scodoc
@permission_required(Permission.ScoView)
def bulletin_but(formsemestre_id: int, etudid: int = None):
"""Page HTML affichant le bulletin BUT simplifié"""
etud: Identite = Identite.query.get_or_404(etudid)
formsemestre: FormSemestre = (
FormSemestre.query.filter_by(id=formsemestre_id)
.join(FormSemestreInscription)
.filter_by(etudid=etudid)
.first_or_404()
)
bulletins_sem = BulletinBUT(formsemestre)
bul = bulletins_sem.bulletin_etud(etud, formsemestre) # dict
decision_ues = {x["acronyme"]: x for x in bul["semestre"]["decision_ue"]}
cursus = cursus_but.EtudCursusBUT(etud, formsemestre.formation)
refcomp = formsemestre.formation.referentiel_competence
if refcomp is None:
raise ScoNoReferentielCompetences(formation=formsemestre.formation)
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
refcomp, etud
)
ects_total = sum((v.ects() for v in ue_validation_by_niveau.values()))
logo = find_logo(logoname="header", dept_id=g.scodoc_dept_id)
return render_template(
"but/bulletin_court_page.j2",
bul=bul,
bulletins_sem=bulletins_sem,
cursus=cursus,
datetime=datetime,
decision_ues=decision_ues,
ects_total=ects_total,
etud=etud,
formsemestre=formsemestre,
logo=logo,
sco=ScoData(formsemestre=formsemestre, etud=etud),
time=time,
ue_validation_by_niveau=ue_validation_by_niveau,
)

View File

@ -6,7 +6,7 @@ from PIL import Image as PILImage
def ImageScale(img_file, maxx, maxy):
im = PILImage.open(img_file)
im.thumbnail((maxx, maxy), PILImage.ANTIALIAS)
im.thumbnail((maxx, maxy), PILImage.LANCZOS)
out_file_str = io.BytesIO()
im.save(out_file_str, im.format)
out_file_str.seek(0)
@ -20,7 +20,7 @@ def ImageScaleH(img_file, W=None, H=90):
if W is None:
# keep aspect
W = int((im.size[0] * H) / float(im.size[1]))
im.thumbnail((W, H), PILImage.ANTIALIAS)
im.thumbnail((W, H), PILImage.LANCZOS)
out_file_str = io.BytesIO()
im.save(out_file_str, im.format)
out_file_str.seek(0)

View File

@ -426,6 +426,7 @@ def dict_decision_jury(
etud: Identite, formsemestre: FormSemestre, with_decisions: bool = False
) -> dict:
"""dict avec decision pour bulletins json
- autorisation_inscription
- decision : décision semestre
- decision_ue : list des décisions UE
- situation
@ -511,7 +512,10 @@ def dict_decision_jury(
d["autorisation_inscription"] = []
for aut in decision["autorisations"]:
d["autorisation_inscription"].append(
dict(semestre_id=aut["semestre_id"])
dict(
semestre_id=aut["semestre_id"],
date=aut["date"].isoformat() if aut["date"] else None,
)
)
else:
d["decision"] = dict(code="", etat="DEM")

View File

@ -33,7 +33,6 @@ avec `ext` membre de LOGOS_IMAGES_ALLOWED_TYPES (= jpg, png)
SCODOC_LOGOS_DIR /opt/scodoc-data/config/logos
"""
import glob
import imghdr
import os
import re
import shutil
@ -41,6 +40,7 @@ from pathlib import Path
from flask import current_app, url_for
from PIL import Image as PILImage
import puremagic
from werkzeug.utils import secure_filename
from app import log
@ -51,99 +51,6 @@ from app.scodoc.sco_exceptions import ScoValueError
GLOBAL = "_" # category for server level logos
def find_logo(logoname, dept_id=None, strict=False, prefix=scu.LOGO_FILE_PREFIX):
"""
"Recherche un logo 'name' existant.
Deux strategies:
si strict:
reherche uniquement dans le département puis si non trouvé au niveau global
sinon
On recherche en local au dept d'abord puis si pas trouvé recherche globale
quelque soit la stratégie, retourne None si pas trouvé
:param logoname: le nom recherche
:param dept_id: l'id du département dans lequel se fait la recherche (None si global)
:param strict: stratégie de recherche (strict = False => dept ou global)
:param prefix: le prefix utilisé (parmi scu.LOGO_FILE_PREFIX / scu.BACKGROUND_FILE_PREFIX)
:return: un objet Logo désignant le fichier image trouvé (ou None)
"""
logo = Logo(logoname, dept_id, prefix).select()
if logo is None and not strict:
logo = Logo(logoname=logoname, dept_id=None, prefix=prefix).select()
return logo
def delete_logo(name, dept_id=None):
"""Delete all files matching logo (dept_id, name) (including all allowed extensions)
Args:
name: The name of the logo
dept_id: the dept_id (if local). Use None to destroy globals logos
"""
logo = find_logo(logoname=name, dept_id=dept_id)
while logo is not None:
os.unlink(logo.select().filepath)
logo = find_logo(logoname=name, dept_id=dept_id)
def write_logo(stream, name, dept_id=None):
"""Crée le fichier logo sur le serveur.
Le suffixe du fichier (parmi LOGO_IMAGES_ALLOWED_TYPES) est déduit du contenu du stream"""
Logo(logoname=name, dept_id=dept_id).create(stream)
def rename_logo(old_name, new_name, dept_id):
logo = find_logo(old_name, dept_id, True)
logo.rename(new_name)
def list_logos():
"""Crée l'inventaire de tous les logos existants.
L'inventaire se présente comme un dictionnaire de dictionnaire de Logo:
[None][name] pour les logos globaux
[dept_id][name] pour les logos propres à un département (attention id numérique du dept)
Les départements sans logos sont absents du résultat
"""
inventory = {None: _list_dept_logos()} # logos globaux (header / footer)
for dept in Departement.query.filter_by(visible=True).all():
logos_dept = _list_dept_logos(dept_id=dept.id)
if logos_dept:
inventory[dept.id] = _list_dept_logos(dept.id)
return inventory
def _list_dept_logos(dept_id=None, prefix=scu.LOGO_FILE_PREFIX):
"""Inventorie toutes les images existantes pour un niveau (GLOBAL ou un département).
retourne un dictionnaire de Logo [logoname] -> Logo
les noms des fichiers concernés doivent être de la forme: <rep>/<prefix><name>.<suffixe>
<rep> : répertoire de recherche (déduit du dept_id)
<prefix>: le prefix (LOGO_FILE_PREFIX pour les logos)
<suffix>: un des suffixes autorisés
:param dept_id: l'id du departement concerné (si None -> global)
:param prefix: le préfixe utilisé
:return: le résultat de la recherche ou None si aucune image trouvée
"""
allowed_ext = "|".join(scu.LOGOS_IMAGES_ALLOWED_TYPES)
# parse filename 'logo_<logoname>.<ext> . be carefull: logoname may include '.'
filename_parser = re.compile(f"{prefix}(([^.]*.)+)({allowed_ext})")
logos = {}
path_dir = Path(scu.SCODOC_LOGOS_DIR)
if dept_id:
path_dir = Path(
os.path.sep.join(
[scu.SCODOC_LOGOS_DIR, scu.LOGOS_DIR_PREFIX + str(dept_id)]
)
)
if path_dir.exists():
for entry in path_dir.iterdir():
if os.access(path_dir.joinpath(entry).absolute(), os.R_OK):
result = filename_parser.match(entry.name)
if result:
logoname = result.group(1)[
:-1
] # retreive logoname from filename (less final dot)
logos[logoname] = Logo(logoname=logoname, dept_id=dept_id).select()
return logos if len(logos.keys()) > 0 else None
class Logo:
"""Responsable des opérations (select, create), du calcul des chemins et url
ainsi que de la récupération des informations sur un logo.
@ -212,7 +119,7 @@ class Logo:
def create(self, stream):
img_type = guess_image_type(stream)
if img_type not in scu.LOGOS_IMAGES_ALLOWED_TYPES:
raise ScoValueError("type d'image invalide")
raise ScoValueError(f"type d'image invalide ({img_type})")
self._set_format(img_type)
self._ensure_directory_exists()
filename = self.basepath + "." + self.suffix
@ -310,14 +217,118 @@ class Logo:
)
old_path.rename(new_path)
def html(self) -> str:
"élément HTML img affichant ce logo"
return f"""<img class="sco_logo" src="{self.get_url()}" alt="Logo {self.logoname}">"""
def find_logo(
logoname: str,
dept_id: int | None = None,
strict: bool = False,
prefix: str = scu.LOGO_FILE_PREFIX,
) -> Logo | None:
"""
"Recherche un logo 'name' existant.
Deux strategies:
si strict:
recherche uniquement dans le département puis si non trouvé au niveau global
sinon
On recherche en local au dept d'abord puis si pas trouvé recherche globale
quelque soit la stratégie, retourne None si pas trouvé
:param logoname: le nom recherche
:param dept_id: l'id du département dans lequel se fait la recherche (None si global)
:param strict: stratégie de recherche (strict = False => dept ou global)
:param prefix: le prefix utilisé (parmi scu.LOGO_FILE_PREFIX / scu.BACKGROUND_FILE_PREFIX)
:return: un objet Logo désignant le fichier image trouvé (ou None)
"""
logo = Logo(logoname, dept_id, prefix).select()
if logo is None and not strict:
logo = Logo(logoname=logoname, dept_id=None, prefix=prefix).select()
return logo
def delete_logo(name, dept_id=None):
"""Delete all files matching logo (dept_id, name) (including all allowed extensions)
Args:
name: The name of the logo
dept_id: the dept_id (if local). Use None to destroy globals logos
"""
logo = find_logo(logoname=name, dept_id=dept_id)
while logo is not None:
os.unlink(logo.select().filepath)
logo = find_logo(logoname=name, dept_id=dept_id)
def write_logo(stream, name, dept_id=None):
"""Crée le fichier logo sur le serveur.
Le suffixe du fichier (parmi LOGO_IMAGES_ALLOWED_TYPES) est déduit du contenu du stream
"""
Logo(logoname=name, dept_id=dept_id).create(stream)
def rename_logo(old_name, new_name, dept_id):
logo = find_logo(old_name, dept_id, True)
logo.rename(new_name)
def list_logos():
"""Crée l'inventaire de tous les logos existants.
L'inventaire se présente comme un dictionnaire de dictionnaire de Logo:
[None][name] pour les logos globaux
[dept_id][name] pour les logos propres à un département (attention id numérique du dept)
Les départements sans logos sont absents du résultat
"""
inventory = {None: _list_dept_logos()} # logos globaux (header / footer)
for dept in Departement.query.filter_by(visible=True).all():
logos_dept = _list_dept_logos(dept_id=dept.id)
if logos_dept:
inventory[dept.id] = _list_dept_logos(dept.id)
return inventory
def _list_dept_logos(dept_id=None, prefix=scu.LOGO_FILE_PREFIX):
"""Inventorie toutes les images existantes pour un niveau (GLOBAL ou un département).
retourne un dictionnaire de Logo [logoname] -> Logo
les noms des fichiers concernés doivent être de la forme: <rep>/<prefix><name>.<suffixe>
<rep> : répertoire de recherche (déduit du dept_id)
<prefix>: le prefix (LOGO_FILE_PREFIX pour les logos)
<suffix>: un des suffixes autorisés
:param dept_id: l'id du departement concerné (si None -> global)
:param prefix: le préfixe utilisé
:return: le résultat de la recherche ou None si aucune image trouvée
"""
allowed_ext = "|".join(scu.LOGOS_IMAGES_ALLOWED_TYPES)
# parse filename 'logo_<logoname>.<ext> . be carefull: logoname may include '.'
filename_parser = re.compile(f"{prefix}(([^.]*.)+)({allowed_ext})")
logos = {}
path_dir = Path(scu.SCODOC_LOGOS_DIR)
if dept_id:
path_dir = Path(
os.path.sep.join(
[scu.SCODOC_LOGOS_DIR, scu.LOGOS_DIR_PREFIX + str(dept_id)]
)
)
if path_dir.exists():
for entry in path_dir.iterdir():
if os.access(path_dir.joinpath(entry).absolute(), os.R_OK):
result = filename_parser.match(entry.name)
if result:
logoname = result.group(1)[
:-1
] # retreive logoname from filename (less final dot)
logos[logoname] = Logo(logoname=logoname, dept_id=dept_id).select()
return logos if len(logos.keys()) > 0 else None
def guess_image_type(stream) -> str:
"guess image type from header in stream"
header = stream.read(512)
stream.seek(0)
fmt = imghdr.what(None, header)
if not fmt:
ext = puremagic.from_stream(stream)
if not ext or not ext.startswith("."):
return None
fmt = ext[1:] # remove leading .
if fmt == "jfif":
fmt = "jpg"
return fmt if fmt != "jpeg" else "jpg"

View File

@ -338,7 +338,7 @@ def scale_height(img, W=None, H=REDUCED_HEIGHT):
if W is None:
# keep aspect
W = int((img.size[0] * H) / img.size[1])
img.thumbnail((W, H), PILImage.ANTIALIAS)
img.thumbnail((W, H), PILImage.LANCZOS)
return img

View File

@ -0,0 +1,124 @@
@media print{
body{
width: 21cm;
height: 29.7cm;
}
}
div.but_bul_court {
width: 17cm;
display: grid;
grid-template-columns: 6cm 11cm;
font-size: 11pt;
}
#infos_etudiant {
grid-column: 1;
grid-row: 1;
border-radius: 3mm;
border: 1px solid black;
background-color: white;
padding: 5mm;
}
.nom {
font-weight: bold;
font-size: 14pt;
}
#logo {
grid-column: 2;
grid-row: 1;
justify-self: end;
}
#logo img {
text-align: right;
height: 3cm;
}
div.but_bul_court table {
border-collapse: collapse;
border: 2px solid black;
}
div.but_bul_court table th,
div.but_bul_court table td {
background-color: white;
border: 1px solid black; /* Thin black border between cells */
padding: 2px 4px 2px 4px; /* Padding inside the cells */
}
table td.col_ue {
width: 18mm;
}
#ues {
grid-row: 2;
grid-column: 1/3;
justify-self: end;
margin-top: 5mm;
margin-bottom: 5mm;
}
#ues tr.titre_table th {
background-color: rgb(183,235,255);
padding: 2mm;
}
tr.titres_ues td, tr.jury td {
font-weight: bold;
}
table.resultats_modules {
width: 100%;
}
#ressources {
grid-row: 3;
grid-column: 1/3;
margin-bottom: 5mm;
width: 100%;
}
#ressources tr.titres_ues td:first-child {
background-color: rgb(255, 192, 0);
}
#saes {
grid-row: 4;
grid-column: 1/3;
margin-bottom: 5mm;
width: 100%;
}
#saes tr.titres_ues td:first-child {
background-color: rgb(176, 255, 99);
}
#row_situation {
grid-row: 5;
grid-column: 1/3;
display: grid;
grid-template-columns: auto auto;
}
#cursus_etud, #situation {
grid-row: 1;
}
#situation {
background-color: white;
justify-self: end;
margin-left: 1cm;
border-radius: 3mm;
border: 1px solid black;
padding: 5mm;
}
#footer {
grid-row: 6;
grid-column: 1/3;
margin-top: 5mm;
font-size: 9pt;
font-style: italic;
}
.but_bul_court .cursus_but {
margin-left: 0px;
}

View File

@ -0,0 +1,162 @@
{% extends "sco_page.j2" %}
{% block styles %}
{{super()}}
<link href="{{scu.STATIC_DIR}}/css/jury_but.css" rel="stylesheet" type="text/css" />
<link href="{{scu.STATIC_DIR}}/css/cursus_but.css" rel="stylesheet" type="text/css" />
<link href="{{scu.STATIC_DIR}}/css/bulletin_court.css" rel="stylesheet" type="text/css" />
{% endblock %}
{% macro table_modules(mod_type, title) -%}
<table class="resultats_modules">
<thead>
<tr class="titre_table">
<th colspan="2"></th>
<th colspan="{{ bul.ues|length }}">Unités d'enseignement</th>
</tr>
<tr class="titres_ues">
<td colspan="2">{{title}}</td>
{% for ue in bul.ues %}
<td class="col_ue">{{ue}}</td>
{% endfor %}
</tr>
</thead>
<tbody>
{% for mod in bul[mod_type] %}
<tr>
<td>{{mod}}</td>
<td>{{bul[mod_type][mod].titre}}</td>
{% for ue in bul.ues %}
<td>{{
bul.ues[ue][mod_type][mod].moyenne
if mod in bul.ues[ue][mod_type] else ""
}}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{%- endmacro %}
{% block app_content %}
<div class="but_bul_court">
<div id="infos_etudiant">
<div class="nom">{{etud.nomprenom}}</div>
<div class="formation">BUT {{formsemestre.formation.referentiel_competence.specialite}}</div>
{% if formsemestre.etuds_inscriptions[etud.id].parcour %}
<div class="parcours">Parcours {{formsemestre.etuds_inscriptions[etud.id].parcour.code}}</div>
{% endif %}
<div class="annee_scolaire">Année {{formsemestre.annee_scolaire_str()}}</div>
<div class="semestre">Semestre {{formsemestre.semestre_id}}</div>
</div>
<div id="logo">
{% if logo %}
{{logo.html()|safe}}
{% endif %}
</div>
<div id="ues">
<table>
<thead>
<tr class="titre_table">
<th colspan="{{ 1 + bul.ues|length }}">Unités d'enseignement du semestre {{formsemestre.semestre_id}}</th>
</tr>
<tr class="titres_ues">
<td></td>
{% for ue in bul.ues %}
<td class="col_ue">{{ue}}</td>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td>Moyenne</td>
{% for ue in bul.ues %}
<td class="col_ue">{{bul.ues[ue].moyenne.value}}</td>
{% endfor %}
</tr>
<tr>
<td>Bonus</td>
{% for ue in bul.ues %}
<td class="col_ue">{{bul.ues[ue].bonus if bul.ues[ue].bonus != "00.00" else ""}}</td>
{% endfor %}
</tr>
<tr>
<td>Malus</td>
{% for ue in bul.ues %}
<td class="col_ue">{{bul.ues[ue].malus if bul.ues[ue].malus != "00.00" else ""}}</td>
{% endfor %}
</tr>
<tr>
<td>Rang</td>
{% for ue in bul.ues %}
<td class="col_ue">{{bul.ues[ue].moyenne.rang}}</td>
{% endfor %}
</tr>
<tr>
<td>Effectif</td>
{% for ue in bul.ues %}
<td class="col_ue">{{bul.ues[ue].moyenne.total}}</td>
{% endfor %}
</tr>
<tr>
<td>ECTS</td>
{% for ue in bul.ues %}
<td class="col_ue">{{bul.ues[ue].moyenne.ects}}</td>
{% endfor %}
</tr>
<tr class="jury">
<td>Jury</td>
{% for ue in bul.ues %}
<td class="col_ue">{{decision_ues[ue].code}}</td>
{% endfor %}
</tr>
</tbody>
</table>
</div>
<div id="ressources">
{{ table_modules("ressources", "Ressources") }}
</div>
<div id="saes">
{{ table_modules("saes", "Situations d'Apprentissage et d'Évaluation (SAÉ)") }}
</div>
<div id="row_situation">
<div id="cursus_etud">
{% include "but/cursus_etud.j2" %}
</div>
<div id="situation">
<div>ECTS acquis : {{ects_total}}</div>
<div class="descr_jury">
{% if bul.semestre.decision_annee %}
Jury tenu le {{
datetime.datetime.fromisoformat(bul.semestre.decision_annee.date).strftime("%d/%m/%Y à %H:%M")
}},
année BUT {{bul.semestre.decision_annee.code}}.
{% endif %}
{% set virg = joiner(", ") %}
{% for aut in bul.semestre.autorisation_inscription -%}
{% if loop.first %}
Autorisé à s'inscrire en
{% endif %}
{{- virg() }}S{{aut.semestre_id -}}
{%- if loop.last -%}
.
{%- endif -%}
{%- endfor %}
</div>
</div>
</div>
<div id="footer">
Bulletin généré par ScoDoc le {{time.strftime("%d/%m/%Y à %Hh%M")}}. Explication des codes sur
<a href="https://scodoc.org/CodesJuryBUT">https://scodoc.org/CodesJuryBUT</a>
</div>
</div>
{% endblock %}

View File

@ -1,7 +1,7 @@
{# -*- mode: jinja-html -*- #}
{# Description un semestre (barre de menu et infos) #}
<!-- formsemestre_header -->
<div class="formsemestre_page_title">
<div class="formsemestre_page_title noprint">
<div class="infos">
<span class="semtitle"><a class="stdlink" title="{{sco.sem.session_id()}}" href="{{
url_for('notes.formsemestre_status',

View File

@ -2,7 +2,7 @@
{# Element HTML decrivant un semestre (barre de menu et infos) #}
{# was formsemestre_page_title #}
<div class="formsemestre_page_title">
<div class="formsemestre_page_title noprint">
<div class="infos">
<span class="semtitle"><a class="stdlink" title="{{formsemestre.session_id()}}" href="{{url_for('notes.formsemestre_status',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)}}">{{formsemestre.titre}}</a>

View File

@ -35,7 +35,7 @@ from operator import itemgetter
import time
import flask
from flask import abort, flash, redirect, render_template, url_for
from flask import flash, redirect, render_template, url_for
from flask import g, request
from flask_login import current_user
@ -44,6 +44,7 @@ from app import models
from app.auth.models import User
from app.but import (
apc_edit_ue,
bulletin_but_court,
cursus_but,
jury_edit_manual,
jury_but,
@ -58,7 +59,6 @@ from app.comp import jury, res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import (
Formation,
ScolarFormSemestreValidation,
ScolarAutorisationInscription,
ScolarNews,
Scolog,

View File

@ -59,6 +59,7 @@ Pillow==10.0.0
platformdirs==3.10.0
pluggy==1.2.0
psycopg2==2.9.6
puremagic==1.15
py==1.11.0
pycparser==2.21
pydot==1.4.2

View File

@ -3,5 +3,5 @@ Version: x.y.z
Architecture: amd64
Maintainer: Emmanuel Viennet <emmanuel@viennet.net>
Description: ScoDoc 9
Un logiciel pour le suivi de la scolarité universitaire.
Depends: adduser, curl, gcc, graphviz, graphviz-dev, libpq-dev, postfix|exim4, cracklib-runtime, libcrack2-dev, python3-dev, python3-venv, python3-pip, python3-wheel, nginx, postgresql, libpq-dev, redis, ufw
Un logiciel pour le suivi de la scolarité universitaire.
Depends: adduser, curl, gcc, graphviz, graphviz-dev, libpq-dev, postfix|exim4, cracklib-runtime, libcrack2-dev, libpango-1.0-0, pango1.0-tools, python3-dev, python3-venv, python3-pip, python3-wheel, nginx, postgresql, libpq-dev, redis, ufw