image feature + refactor
This commit is contained in:
parent
73e019de9a
commit
d64efb663a
13 changed files with 221 additions and 37 deletions
|
@ -3,7 +3,7 @@ import json
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
from core.models import Berichtsheft
|
from core.models import Berichtsheft
|
||||||
from core.reports import DailyReport, WeeklyReport
|
from core.reports import DailyReport, WeeklyReport, choose_report_kind
|
||||||
import core.util
|
import core.util
|
||||||
|
|
||||||
|
|
||||||
|
@ -41,12 +41,8 @@ class AzureUser:
|
||||||
# TODO : Implement
|
# TODO : Implement
|
||||||
return "weekly"
|
return "weekly"
|
||||||
|
|
||||||
def get_report_kind_form(self, request=None):
|
def get_report_kind_form(self, request=None, files=None):
|
||||||
match self.get_report_kind():
|
return choose_report_kind(self.get_report_kind(), request, files)
|
||||||
case "weekly":
|
|
||||||
return WeeklyReport(request)
|
|
||||||
case "daily":
|
|
||||||
return DailyReport(request)
|
|
||||||
|
|
||||||
def latest_report(self):
|
def latest_report(self):
|
||||||
return self.reports().order_by("-year", "-week").first()
|
return self.reports().order_by("-year", "-week").first()
|
||||||
|
|
17
core/migrations/0012_berichtsheft_image.py
Normal file
17
core/migrations/0012_berichtsheft_image.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 4.2.17 on 2024-12-06 09:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0011_berichtsheftdraft"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="berichtsheft",
|
||||||
|
name="image",
|
||||||
|
field=models.BinaryField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -39,6 +39,7 @@ class Berichtsheft(models.Model):
|
||||||
content = models.JSONField()
|
content = models.JSONField()
|
||||||
needs_rewrite = models.BooleanField(default=False)
|
needs_rewrite = models.BooleanField(default=False)
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
image = models.BinaryField(null=True, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Berichtsheft: {self.user}, Year: {self.year}, Week: {self.week}"
|
return f"Berichtsheft: {self.user}, Year: {self.year}, Week: {self.week}"
|
||||||
|
|
155
core/reports.py
155
core/reports.py
|
@ -1,6 +1,6 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from core.styles import label_span
|
from core.styles import label_span, STYLE
|
||||||
|
|
||||||
|
|
||||||
class WeeklyReport(forms.Form):
|
class WeeklyReport(forms.Form):
|
||||||
|
@ -10,7 +10,7 @@ class WeeklyReport(forms.Form):
|
||||||
widget=forms.TextInput(
|
widget=forms.TextInput(
|
||||||
attrs={
|
attrs={
|
||||||
"placeholder": "Abteilung",
|
"placeholder": "Abteilung",
|
||||||
"class": "w-full p-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-400 mb-5",
|
"class": STYLE["text-input"],
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -19,7 +19,7 @@ class WeeklyReport(forms.Form):
|
||||||
max_length=300,
|
max_length=300,
|
||||||
widget=forms.Textarea(
|
widget=forms.Textarea(
|
||||||
attrs={
|
attrs={
|
||||||
"class": "w-full p-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-400 mb-5",
|
"class": STYLE["text-input"],
|
||||||
"rows": 5,
|
"rows": 5,
|
||||||
"placeholder": "Betriebliche Tätigkeiten",
|
"placeholder": "Betriebliche Tätigkeiten",
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ class WeeklyReport(forms.Form):
|
||||||
max_length=600,
|
max_length=600,
|
||||||
widget=forms.Textarea(
|
widget=forms.Textarea(
|
||||||
attrs={
|
attrs={
|
||||||
"class": "w-full p-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-400 mb-5",
|
"class": STYLE["text-input"],
|
||||||
"rows": 10,
|
"rows": 10,
|
||||||
"placeholder": "Thema der Woche",
|
"placeholder": "Thema der Woche",
|
||||||
}
|
}
|
||||||
|
@ -41,12 +41,31 @@ class WeeklyReport(forms.Form):
|
||||||
max_length=300,
|
max_length=300,
|
||||||
widget=forms.Textarea(
|
widget=forms.Textarea(
|
||||||
attrs={
|
attrs={
|
||||||
"class": "w-full p-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-400 mb-5",
|
"class": STYLE["text-input"],
|
||||||
"rows": 5,
|
"rows": 5,
|
||||||
"placeholder": "Berufsschule",
|
"placeholder": "Berufsschule",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
img = forms.ImageField(
|
||||||
|
label=label_span("Bild"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.ClearableFileInput(
|
||||||
|
attrs={
|
||||||
|
"class": "block w-full p-2 text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400",
|
||||||
|
"accept": "image/*",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_names(self):
|
||||||
|
return {
|
||||||
|
"department": self.fields["department"].label,
|
||||||
|
"company_text": self.fields["company_text"].label,
|
||||||
|
"week_topic": self.fields["week_topic"].label,
|
||||||
|
"school_text": self.fields["school_text"].label,
|
||||||
|
}
|
||||||
|
|
||||||
def content_values(self) -> dict:
|
def content_values(self) -> dict:
|
||||||
if self.is_valid():
|
if self.is_valid():
|
||||||
|
@ -57,11 +76,121 @@ class WeeklyReport(forms.Form):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class DailyReport:
|
class DailyReport(forms.Form):
|
||||||
department = forms.CharField(label="Abteilung", max_length=150)
|
department = forms.CharField(
|
||||||
week_topic = forms.CharField(label="Thema der Woche", max_length=600)
|
label=label_span("Abteilung"),
|
||||||
monday_text = forms.CharField(label="Berufsschule", max_length=300)
|
max_length=150,
|
||||||
tuesday_text = forms.CharField(label="Dienstag", max_length=300)
|
widget=forms.TextInput(
|
||||||
wednesday_text = forms.CharField(label="Mittwoch", max_length=300)
|
attrs={
|
||||||
thursday_text = forms.CharField(label="Donnerstag", max_length=300)
|
"placeholder": "Abteilung",
|
||||||
friday_text = forms.CharField(label="Freitag", max_length=300)
|
"class": STYLE["text-input"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
week_topic = forms.CharField(
|
||||||
|
label=label_span("Thema der Woche"),
|
||||||
|
max_length=600,
|
||||||
|
widget=forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"class": STYLE["text-input"],
|
||||||
|
"rows": 5,
|
||||||
|
"placeholder": "Thema der Woche",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monday_text = forms.CharField(
|
||||||
|
label=label_span("Montag"),
|
||||||
|
max_length=300,
|
||||||
|
widget=forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"class": STYLE["text-input"],
|
||||||
|
"rows": 5,
|
||||||
|
"placeholder": "Montag",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tuesday_text = forms.CharField(
|
||||||
|
label=label_span("Dienstag"),
|
||||||
|
max_length=300,
|
||||||
|
widget=forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"class": STYLE["text-input"],
|
||||||
|
"rows": 5,
|
||||||
|
"placeholder": "Dienstag",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
wednesday_text = forms.CharField(
|
||||||
|
label=label_span("Mittwoch"),
|
||||||
|
max_length=300,
|
||||||
|
widget=forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"class": STYLE["text-input"],
|
||||||
|
"rows": 5,
|
||||||
|
"placeholder": "Mittwoch,",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
thursday_text = forms.CharField(
|
||||||
|
label=label_span("Donnerstag"),
|
||||||
|
max_length=300,
|
||||||
|
widget=forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"class": STYLE["text-input"],
|
||||||
|
"rows": 5,
|
||||||
|
"placeholder": "Donnerstag",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
friday_text = forms.CharField(
|
||||||
|
label=label_span("Freitag"),
|
||||||
|
max_length=300,
|
||||||
|
widget=forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"class": STYLE["text-input"],
|
||||||
|
"rows": 5,
|
||||||
|
"placeholder": "Freitag",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
img = forms.ImageField(
|
||||||
|
label=label_span("Bild"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.ClearableFileInput(
|
||||||
|
attrs={
|
||||||
|
"class": "block w-full p-2 text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400",
|
||||||
|
"accept": "image/*",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_names(self) -> dict:
|
||||||
|
return {
|
||||||
|
"department": self.fields["department"].label,
|
||||||
|
"week_topic": self.fields["week_topic"].label,
|
||||||
|
"monday_text": self.fields["monday_text"].label,
|
||||||
|
"tuesday_text": self.fields["tuesday_text"].label,
|
||||||
|
"wednesday_text": self.fields["wednesday_text"].label,
|
||||||
|
"thursday_text": self.fields["thursday_text"].label,
|
||||||
|
"friday_text": self.fields["friday_text"].label,
|
||||||
|
}
|
||||||
|
|
||||||
|
def content_values(self) -> dict:
|
||||||
|
if self.is_valid():
|
||||||
|
return {
|
||||||
|
"week_topic": self.cleaned_data["week_topic"],
|
||||||
|
"monday_text": self.cleaned_data["monday_text"],
|
||||||
|
"tuesday_text": self.cleaned_data["tuesday_text"],
|
||||||
|
"wednesday_text": self.cleaned_data["wednesday_text"],
|
||||||
|
"thursday_text": self.cleaned_data["thursday_text"],
|
||||||
|
"friday_text": self.cleaned_data["friday_text"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def choose_report_kind(kind, request=None, files=None):
|
||||||
|
match kind:
|
||||||
|
case "weekly":
|
||||||
|
return WeeklyReport(request, files)
|
||||||
|
case "daily":
|
||||||
|
return DailyReport(request, files)
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
STYLE = {
|
STYLE = {
|
||||||
"red_btn": "text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-4 focus:ring-red-300 font-medium rounded-full text-sm px-5 py-2.5 text-center me-2 mb-2 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900",
|
"red_btn": "text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-4 focus:ring-red-300 font-medium rounded-full text-sm px-5 py-2.5 text-center me-2 mb-2 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900",
|
||||||
"card": "bg-white drop-shadow-md p-4 mx-auto mt-5 aspect-[2/3] hover:drop-shadow-xl hover:scale-[0.85] scale-[0.8] lg:hover:scale-[0.95] lg:scale-[0.9] transition-all duration-50 transform ease-in-out w-60 sm:w-80",
|
"card": "bg-white drop-shadow-md p-4 mx-auto mt-5 aspect-[2/3] hover:drop-shadow-xl hover:scale-[0.85] scale-[0.8] lg:hover:scale-[0.95] lg:scale-[0.9] transition-all duration-50 transform ease-in-out w-60 sm:w-80",
|
||||||
|
"text-input": "w-full p-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-400 mb-5",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
{% if late_reports > 1 %}
|
{% if late_reports > 1 %}
|
||||||
|
|
||||||
<div class="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mr-10 ml-10 mt-6" role="alert">
|
<div class="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mr-10 ml-10 mt-2" role="alert">
|
||||||
<p class="font-bold"> Du bist nicht aktuell! </p>
|
<p class="font-bold"> Du bist nicht aktuell! </p>
|
||||||
<p> Du hast noch {{ late_reports }} Berichtshefte nachzuschreiben. </p>
|
<p> Du hast noch {{ late_reports }} Berichtshefte nachzuschreiben. </p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -16,13 +16,26 @@
|
||||||
<div class="border-t border-gray-200 mt-4 pt-4">
|
<div class="border-t border-gray-200 mt-4 pt-4">
|
||||||
<h2 class="text-lg font-medium text-gray-800 mb-2">Content:</h2>
|
<h2 class="text-lg font-medium text-gray-800 mb-2">Content:</h2>
|
||||||
<dl class="space-y-2">
|
<dl class="space-y-2">
|
||||||
{% load markdown %}
|
<div class="flex items-start space-x-4 justify-between">
|
||||||
|
<div>
|
||||||
|
{% load access markdown %}
|
||||||
{% for key, value in report.content.items %}
|
{% for key, value in report.content.items %}
|
||||||
<div class="justify-between items-start">
|
<div class="justify-between items-start">
|
||||||
<h4 class="text-sm font-medium text-gray-600">{{ key }}</h4>
|
<h4 class="text-sm font-medium text-gray-600">{{ form.display_names|access:key|safe }}</h4>
|
||||||
<span class="text-sm text-gray-800 mb-4">{{ value|markdown|safe }}</span>
|
<div class="text-sm text-gray-800 mb-4">{{ value|markdown|safe }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if report.image is not None %}
|
||||||
|
{% load b64 %}
|
||||||
|
<div class="w-36">
|
||||||
|
<img src="data:image/*;base64,{{ report.image|b64 }}" width="128"
|
||||||
|
class="-mt-8 w-32 h-32 border-2 border-black cursor-pointer rounded-lg shadow-lg hover:shadow-2xl transition-all duration-300">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<!-- Main wrapper that will hold the header, sidebar, and content -->
|
<!-- Main wrapper that will hold the header, sidebar, and content -->
|
||||||
<div class="flex flex-col min-h-screen">
|
<div class="flex flex-col min-h-screen">
|
||||||
<header class="w-full bg-red-600 text-white flex py-4 shadow-md max-h-20 h-full max-h-20">
|
<header class="w-full bg-red-600 text-white flex py-4 shadow-md max-h-20 h-full max-h-20">
|
||||||
<button id="menu-toggle" class="text-2xl focus:outline-none ml-5">
|
<button id="menu-toggle" class="text-2xl focus:outline-none ml-5" onclick="toggleSidepanel()">
|
||||||
☰
|
☰
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@ -39,10 +39,8 @@
|
||||||
|
|
||||||
<!-- Script to toggle the sidebar -->
|
<!-- Script to toggle the sidebar -->
|
||||||
<script>
|
<script>
|
||||||
const menuToggle = document.getElementById('menu-toggle');
|
|
||||||
const sidepanel = document.getElementById('sidepanel');
|
|
||||||
|
|
||||||
function toggleSidepanel() {
|
function toggleSidepanel() {
|
||||||
|
const sidepanel = document.getElementById('sidepanel');
|
||||||
if (sidepanel.classList.contains('w-0')) {
|
if (sidepanel.classList.contains('w-0')) {
|
||||||
sidepanel.classList.remove('w-0');
|
sidepanel.classList.remove('w-0');
|
||||||
sidepanel.classList.add('w-60');
|
sidepanel.classList.add('w-60');
|
||||||
|
@ -59,8 +57,6 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
menuToggle.addEventListener('click', () => toggleSidepanel());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" class="space-y-4">
|
<form method="post" class="space-y-4" enctype="multipart/form-data">
|
||||||
|
|
||||||
{% load access set_content %}
|
{% load access set_content %}
|
||||||
|
|
||||||
|
@ -24,7 +24,11 @@
|
||||||
|
|
||||||
{% if field.id_for_label == "id_department" %}
|
{% if field.id_for_label == "id_department" %}
|
||||||
|
|
||||||
|
{% if draft is not None %}
|
||||||
{{ field|set_content:draft.department }}
|
{{ field|set_content:draft.department }}
|
||||||
|
{% else %}
|
||||||
|
{{ field }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{% with content=draft.content|access:field.id_for_label %}
|
{% with content=draft.content|access:field.id_for_label %}
|
||||||
|
|
11
core/templatetags/b64.py
Normal file
11
core/templatetags/b64.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from django import template
|
||||||
|
import base64
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name="b64")
|
||||||
|
def b64(value):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return base64.b64encode(value).decode("utf-8")
|
|
@ -7,4 +7,6 @@ register = template.Library()
|
||||||
|
|
||||||
@register.filter(name="markdown")
|
@register.filter(name="markdown")
|
||||||
def markdown_tag(value):
|
def markdown_tag(value):
|
||||||
return markdown.markdown(bleach.clean(value, tags=[], attributes=[]))
|
return markdown.markdown(
|
||||||
|
bleach.clean(value, tags=[], attributes=[]), extensions=["nl2br"]
|
||||||
|
)
|
||||||
|
|
|
@ -8,6 +8,7 @@ from django.core.paginator import Paginator
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from core.styles import STYLE
|
from core.styles import STYLE
|
||||||
from .util import is_htmx_request, title, htmx_request
|
from .util import is_htmx_request, title, htmx_request
|
||||||
|
from .reports import choose_report_kind
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
@ -24,7 +25,7 @@ def write_new_report(request):
|
||||||
def write_new_report_post(request):
|
def write_new_report_post(request):
|
||||||
user = AzureUser(request)
|
user = AzureUser(request)
|
||||||
|
|
||||||
report_form = user.get_report_kind_form(request.POST)
|
report_form = user.get_report_kind_form(request.POST, request.FILES)
|
||||||
|
|
||||||
if not report_form.is_valid():
|
if not report_form.is_valid():
|
||||||
return HttpResponse("Bad Request", status=400)
|
return HttpResponse("Bad Request", status=400)
|
||||||
|
@ -35,6 +36,9 @@ def write_new_report_post(request):
|
||||||
user.reports().order_by("-year", "-week").first()
|
user.reports().order_by("-year", "-week").first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
upload = report_form.cleaned_data["img"]
|
||||||
|
img = upload.read() if upload is not None else None
|
||||||
|
|
||||||
report = Berichtsheft(
|
report = Berichtsheft(
|
||||||
user=user.id,
|
user=user.id,
|
||||||
kind=user.get_report_kind(),
|
kind=user.get_report_kind(),
|
||||||
|
@ -43,8 +47,13 @@ def write_new_report_post(request):
|
||||||
week=int(current_week),
|
week=int(current_week),
|
||||||
department=report_form.cleaned_data["department"],
|
department=report_form.cleaned_data["department"],
|
||||||
content=report_form.content_values(),
|
content=report_form.content_values(),
|
||||||
|
image=img,
|
||||||
)
|
)
|
||||||
report.save()
|
report.save()
|
||||||
|
|
||||||
|
# Clear draft
|
||||||
|
BerichtsheftDraft.objects.filter(user=user.id).delete()
|
||||||
|
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
@ -159,12 +168,16 @@ def report_detail_page(request, report_id):
|
||||||
user = AzureUser(request)
|
user = AzureUser(request)
|
||||||
|
|
||||||
report = get_object_or_404(Berichtsheft, id=report_id)
|
report = get_object_or_404(Berichtsheft, id=report_id)
|
||||||
|
form = choose_report_kind(report.kind)
|
||||||
|
|
||||||
if report.user != user.id:
|
if report.user != user.id:
|
||||||
return HttpResponse("Nah", status=401)
|
return HttpResponse("Nah", status=401)
|
||||||
|
|
||||||
return htmx_request(
|
return htmx_request(
|
||||||
request, "report.html", {"report": report}, f"Berichtsheft {report.num}"
|
request,
|
||||||
|
"report.html",
|
||||||
|
{"report": report, "form": form},
|
||||||
|
f"Berichtsheft {report.num}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,3 +2,4 @@ Django~=4.2.11
|
||||||
psycopg2
|
psycopg2
|
||||||
markdown
|
markdown
|
||||||
bleach
|
bleach
|
||||||
|
pillow
|
Loading…
Add table
Reference in a new issue