feat: export consolidated user report PDF
Build and publish Docker image / publish (push) Successful in 12s
Build and publish Docker image / publish (push) Successful in 12s
This commit is contained in:
@@ -87,3 +87,59 @@ def render_company_pdf(report, company):
|
|||||||
story.append(Paragraph(f"Итого: {_duration(company_group['minutes'])}, {company_group['amount']} ₽", total))
|
story.append(Paragraph(f"Итого: {_duration(company_group['minutes'])}, {company_group['amount']} ₽", total))
|
||||||
document.build(story)
|
document.build(story)
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def render_user_pdf(report):
|
||||||
|
output = BytesIO()
|
||||||
|
font, bold_font = _font_names()
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
normal = ParagraphStyle("UserReportNormal", parent=styles["Normal"], fontName=font, fontSize=9, leading=12)
|
||||||
|
heading = ParagraphStyle("UserReportHeading", parent=styles["Heading1"], fontName=bold_font, fontSize=18, leading=22)
|
||||||
|
company_heading = ParagraphStyle("UserReportCompany", parent=styles["Heading2"], fontName=bold_font, fontSize=15, leading=18, textColor=colors.HexColor("#172033"))
|
||||||
|
project_heading = ParagraphStyle("UserReportProject", parent=styles["Heading3"], fontName=bold_font, fontSize=12, leading=15)
|
||||||
|
task_title = ParagraphStyle("UserReportTaskTitle", parent=normal, fontName=bold_font, fontSize=9, leading=11)
|
||||||
|
task_description = ParagraphStyle("UserReportTaskDescription", parent=normal, fontSize=8, leading=11, textColor=colors.HexColor("#5a6477"))
|
||||||
|
total = ParagraphStyle("UserReportTotal", parent=normal, fontName=bold_font, fontSize=11, alignment=TA_RIGHT)
|
||||||
|
grand_total = ParagraphStyle("UserReportGrandTotal", parent=total, fontSize=14, leading=18)
|
||||||
|
document = SimpleDocTemplate(output, pagesize=A4, rightMargin=15 * mm, leftMargin=15 * mm, topMargin=15 * mm, bottomMargin=15 * mm)
|
||||||
|
user_name = report["user"].get_full_name() or report["user"].username
|
||||||
|
story = [
|
||||||
|
Paragraph("Общий отчёт о выполненных работах", heading),
|
||||||
|
Spacer(1, 4 * mm),
|
||||||
|
Paragraph(f"<b>Исполнитель:</b> {escape(user_name)}", normal),
|
||||||
|
Paragraph(f"<b>Период:</b> {report['month']:02d}.{report['year']}", normal),
|
||||||
|
Spacer(1, 5 * mm),
|
||||||
|
]
|
||||||
|
for company_group in report["companies"]:
|
||||||
|
story.append(Paragraph(escape(company_group["company"].name), company_heading))
|
||||||
|
for project_group in company_group["projects"].values():
|
||||||
|
story.append(Paragraph(escape(project_group["project"].name), project_heading))
|
||||||
|
rows = [["Дата", "Задача и описание", "Время", "Ставка", "Сумма"]]
|
||||||
|
for task in project_group["tasks"]:
|
||||||
|
rows.append([
|
||||||
|
task.work_date.strftime("%d.%m.%Y"),
|
||||||
|
[
|
||||||
|
Paragraph(escape(task.title), task_title),
|
||||||
|
Spacer(1, 1.5 * mm),
|
||||||
|
Paragraph(escape(task.description), task_description),
|
||||||
|
],
|
||||||
|
_duration(task.duration_minutes), f"{task.hourly_rate} ₽", f"{task.amount} ₽",
|
||||||
|
])
|
||||||
|
rows.append(["", "Итого по проекту", _duration(project_group["minutes"]), "", f"{project_group['amount']} ₽"])
|
||||||
|
table = Table(rows, colWidths=(22 * mm, 78 * mm, 25 * mm, 25 * mm, 25 * mm), repeatRows=1)
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
("FONTNAME", (0, 0), (-1, -1), font), ("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||||
|
("FONTNAME", (0, 0), (-1, 0), bold_font),
|
||||||
|
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e9edf4")),
|
||||||
|
("GRID", (0, 0), (-1, -1), .35, colors.HexColor("#aeb7c5")),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||||
|
]))
|
||||||
|
story.extend((table, Spacer(1, 3 * mm)))
|
||||||
|
story.extend((
|
||||||
|
Paragraph(f"Итого по компании: {_duration(company_group['minutes'])}, {company_group['amount']} ₽", total),
|
||||||
|
Spacer(1, 6 * mm),
|
||||||
|
))
|
||||||
|
story.append(Paragraph(f"Общий итог: {_duration(report['total_minutes'])}, {report['total_amount']} ₽", grand_total))
|
||||||
|
document.build(story)
|
||||||
|
return output.getvalue()
|
||||||
|
|||||||
@@ -61,3 +61,17 @@ class MonthlyReportTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response["Content-Type"], "application/pdf")
|
self.assertEqual(response["Content-Type"], "application/pdf")
|
||||||
self.assertTrue(response.content.startswith(b"%PDF"))
|
self.assertTrue(response.content.startswith(b"%PDF"))
|
||||||
|
|
||||||
|
def test_user_pdf_is_generated_for_all_companies(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("user-report-pdf"), {"year": 2026, "month": 7})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "application/pdf")
|
||||||
|
self.assertEqual(response["Content-Disposition"], 'attachment; filename="report-worker-2026-07.pdf"')
|
||||||
|
self.assertTrue(response.content.startswith(b"%PDF"))
|
||||||
|
|
||||||
|
def test_worker_cannot_export_another_users_general_report(self):
|
||||||
|
other = User.objects.create_user(username="other", password="password123", must_change_password=False)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("user-report-pdf"), {"user": other.pk, "year": 2026, "month": 7})
|
||||||
|
self.assertEqual(response["Content-Disposition"], 'attachment; filename="report-worker-2026-07.pdf"')
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ from . import views
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.monthly_report, name="monthly-report"),
|
path("", views.monthly_report, name="monthly-report"),
|
||||||
|
path("pdf/", views.user_pdf, name="user-report-pdf"),
|
||||||
path("company/<int:company_id>/pdf/", views.company_pdf, name="company-report-pdf"),
|
path("company/<int:company_id>/pdf/", views.company_pdf, name="company-report-pdf"),
|
||||||
]
|
]
|
||||||
|
|||||||
+13
-1
@@ -9,7 +9,7 @@ from apps.accounts.models import User
|
|||||||
from apps.companies.models import Company
|
from apps.companies.models import Company
|
||||||
|
|
||||||
from .services import build_monthly_report
|
from .services import build_monthly_report
|
||||||
from .pdf import render_company_pdf
|
from .pdf import render_company_pdf, render_user_pdf
|
||||||
|
|
||||||
|
|
||||||
def selected_user(request):
|
def selected_user(request):
|
||||||
@@ -50,3 +50,15 @@ def company_pdf(request, company_id):
|
|||||||
response = HttpResponse(pdf, content_type="application/pdf")
|
response = HttpResponse(pdf, content_type="application/pdf")
|
||||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def user_pdf(request):
|
||||||
|
user = selected_user(request)
|
||||||
|
year, month = selected_period(request)
|
||||||
|
report = build_monthly_report(user, year, month)
|
||||||
|
pdf = render_user_pdf(report)
|
||||||
|
filename = f"report-{user.username}-{year}-{month:02d}.pdf"
|
||||||
|
response = HttpResponse(pdf, content_type="application/pdf")
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
return response
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends 'base.html' %}{% load report_tags %}{% block content %}<h1>Месячный отчёт</h1>
|
{% extends 'base.html' %}{% load report_tags %}{% block content %}<h1>Месячный отчёт</h1>
|
||||||
<form method="get" class="filters">{% if user.is_app_admin %}<label>Исполнитель <select name="user">{% for item in users %}<option value="{{ item.pk }}" {% if report.user.pk == item.pk %}selected{% endif %}>{{ item.get_full_name|default:item.username }}</option>{% endfor %}</select></label>{% endif %}<label>Месяц <input name="month" type="number" min="1" max="12" value="{{ report.month }}"></label><label>Год <input name="year" type="number" value="{{ report.year }}"></label><button>Показать</button></form>
|
<form method="get" class="filters">{% if user.is_app_admin %}<label>Исполнитель <select name="user">{% for item in users %}<option value="{{ item.pk }}" {% if report.user.pk == item.pk %}selected{% endif %}>{{ item.get_full_name|default:item.username }}</option>{% endfor %}</select></label>{% endif %}<label>Месяц <input name="month" type="number" min="1" max="12" value="{{ report.month }}"></label><label>Год <input name="year" type="number" value="{{ report.year }}"></label><button>Показать</button></form>
|
||||||
<h2>{{ report.user.get_full_name|default:report.user.username }}</h2>
|
<div class="toolbar"><h2>{{ report.user.get_full_name|default:report.user.username }}</h2><a class="button secondary" href="{% url 'user-report-pdf' %}?user={{ report.user.pk }}&year={{ report.year }}&month={{ report.month }}">PDF общего отчёта</a></div>
|
||||||
{% for group in report.companies %}<section class="report-company"><div class="toolbar"><h3>{{ group.company.name }}</h3>{% if user.is_app_admin %}<a class="button secondary" href="{% url 'company-report-pdf' group.company.pk %}?user={{ report.user.pk }}&year={{ report.year }}&month={{ report.month }}">PDF для клиента</a>{% endif %}</div>
|
{% for group in report.companies %}<section class="report-company"><div class="toolbar"><h3>{{ group.company.name }}</h3>{% if user.is_app_admin %}<a class="button secondary" href="{% url 'company-report-pdf' group.company.pk %}?user={{ report.user.pk }}&year={{ report.year }}&month={{ report.month }}">PDF для клиента</a>{% endif %}</div>
|
||||||
{% for project in group.projects.values %}<h4>{{ project.project.name }}</h4><table><thead><tr><th>Дата</th><th>Задача</th><th>Описание</th><th>Время</th><th>Ставка</th><th>Сумма</th></tr></thead><tbody>{% for task in project.tasks %}<tr><td>{{ task.work_date|date:'d.m.Y' }}</td><td>{{ task.title }}</td><td>{{ task.description }}</td><td>{{ task.duration_minutes|duration }}</td><td>{{ task.hourly_rate }} ₽</td><td>{{ task.amount }} ₽</td></tr>{% endfor %}<tr class="total"><td colspan="3">Итого по проекту</td><td>{{ project.minutes|duration }}</td><td></td><td>{{ project.amount }} ₽</td></tr></tbody></table>{% endfor %}
|
{% for project in group.projects.values %}<h4>{{ project.project.name }}</h4><table><thead><tr><th>Дата</th><th>Задача</th><th>Описание</th><th>Время</th><th>Ставка</th><th>Сумма</th></tr></thead><tbody>{% for task in project.tasks %}<tr><td>{{ task.work_date|date:'d.m.Y' }}</td><td>{{ task.title }}</td><td>{{ task.description }}</td><td>{{ task.duration_minutes|duration }}</td><td>{{ task.hourly_rate }} ₽</td><td>{{ task.amount }} ₽</td></tr>{% endfor %}<tr class="total"><td colspan="3">Итого по проекту</td><td>{{ project.minutes|duration }}</td><td></td><td>{{ project.amount }} ₽</td></tr></tbody></table>{% endfor %}
|
||||||
<p class="company-total">Итого по компании: {{ group.minutes|duration }}, {{ group.amount }} ₽</p></section>{% empty %}<p>За выбранный месяц выполненных задач нет.</p>{% endfor %}
|
<p class="company-total">Итого по компании: {{ group.minutes|duration }}, {{ group.amount }} ₽</p></section>{% empty %}<p>За выбранный месяц выполненных задач нет.</p>{% endfor %}
|
||||||
|
|||||||
Reference in New Issue
Block a user