Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<!DOCTYPE html>
{% load static %}
{% load django_bootstrap5 %}
{% load i18n %}
{% load utils %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ <h5 class="card-header d-flex justify-content-between align-items-center flex-wr
</span>
{% if perms.accounts.manage %}
<span class="d-flex justify-content-end flex-fill flex-wrap gap-2">
<a class="{% tapir_button_link %}"
href="{% url 'coop:user_profile_pdf' share_owner.pk %}">
<span class="material-icons">picture_as_pdf</span>{% translate 'Summary' %}
</a>
<a class="{% tapir_button_link %}"
href="{% url 'coop:shareowner_membership_confirmation' share_owner.pk %}">
<span class="material-icons">file_present</span>{% translate 'Membership confirmation' %}
Expand Down
262 changes: 262 additions & 0 deletions tapir/coop/templates/coop/user_profile_pdf.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
<!DOCTYPE html>
{% load static %}
{% load django_bootstrap5 %}
{% load i18n %}
{% load utils %}
{% load coop %}
{% get_current_language as LANGUAGE_CODE %}
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% translate "Member Profile" %}: {% get_display_name_full shareowner %}</title>
<link rel="stylesheet"
href="{% static 'coop/css/membership_agreement_pdf.css' %}">
<style>
dl {
display: grid;
grid-template-columns: 120px 1fr;
gap: 10px 20px;
margin-bottom: 15px;
}

dt {
font-weight: bold;
}

table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
font-size: 9pt;
}

th, td {
text-align: left;
padding: 8px;
border-bottom: 1px solid #ddd;
}

th {
font-weight: bold;
}

.empty-state {
padding: 10px;
color: #666;
}

.section {
margin-bottom: 20px;
}

h2 {
font-size: 11pt;
margin-bottom: 10px;
border-bottom: 1px solid #ddd;
padding-bottom: 5px;
}
body {
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
}

h1 {
font-size: 14pt;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #333;
}

table {
page-break-inside: avoid;
}

.section {
page-break-inside: avoid;
}

th {
background-color: #f5f5f5;
}

.footer {
margin-top: 40px;
padding-top: 10px;
border-top: 1px solid #ddd;
font-size: 8pt;
color: #999;
}
</style>
</head>
<body>
<h1>{% translate "Member Profile" %}: {% get_display_name_full shareowner %}</h1>
<div class="section">
<h2>{% translate "Personal Data" %}</h2>
<dl>
<dt>{% translate "Name" %}</dt>
<dd>
{{ shareowner.get_info.first_name }} {{ shareowner.get_info.last_name }}
</dd>
<dt>{% translate "Email" %}</dt>
<dd>
{% if shareowner.get_info.email %}
<a href="mailto:{{ shareowner.get_info.email }}">{{ shareowner.get_info.email }}</a>
{% else %}
-
{% endif %}
</dd>
<dt>{% translate "Phone number" %}</dt>
<dd>
{% if shareowner.get_info.phone_number %}
<a href="tel:{{ shareowner.get_info.phone_number }}">{{ shareowner.get_info.phone_number }}</a>
{% else %}
-
{% endif %}
</dd>
<dt>{% translate "Birthdate" %}</dt>
<dd>
{{ shareowner.get_info.birthdate|date:"d.m.Y"|default:"-" }}
</dd>
<dt>{% translate "Address" %}</dt>
<dd>
{{ shareowner.get_info.get_display_address|default:"-" }}
</dd>
<dt>{% translate "Status" %}</dt>
<dd>
{% member_status_colored_text shareowner %}
</dd>
</dl>
</div>
<div class="section">
<h2>{% translate "Shares" %}</h2>
{% if shareowner.share_ownerships.all|length > 0 %}
<table>
<thead>
<tr>
<th style="width: 15%;">#</th>
<th style="width: 28%;">{% translate "Starts at" %}</th>
<th style="width: 28%;">{% translate "Ends at" %}</th>
<th style="width: 29%;">{% translate "Status" %}</th>
</tr>
</thead>
<tbody>
{% for ownership in shareowner.share_ownerships.all %}
<tr>
<td>{{ ownership.id }}</td>
<td>{{ ownership.start_date|date:"d.m.Y"|default:"-" }}</td>
<td>
{% if ownership.end_date %}
{{ ownership.end_date|date:"d.m.Y" }}
{% else %}
-
{% endif %}
</td>
<td>
{% if ownership.is_active %}
{% translate "Active" %}
{% else %}
{% translate "Sold or future" %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">{% translate "No shares found." %}</div>
{% endif %}
</div>
<div class="section">
<h2>{% translate "Payments" %}</h2>
{% if shareowner.credited_payments.all|length > 0 %}
<table>
<thead>
<tr>
<th style="width: 10%;">{% translate "ID" %}</th>
<th style="width: 20%;">{% translate "Date" %}</th>
<th style="width: 50%;">{% translate "Description" %}</th>
<th style="width: 20%;">{% translate "Amount" %}</th>
</tr>
</thead>
<tbody>
{% for payment in shareowner.credited_payments.all %}
<tr>
<td>{{ payment.id }}</td>
<td>{{ payment.payment_date|date:"d.m.Y"|default:"-" }}</td>
<td>{{ payment.description|default:"-" }}</td>
<td>{{ payment.amount|floatformat:2 }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">{% translate "No payments found." %}</div>
{% endif %}
</div>
<div class="section">
<h2>{% translate "Shifts" %} ({% blocktranslate count years=SHIFT_RETENTION_YEARS %}last year{% plural %}max. last {{ years }} years{% endblocktranslate %})</h2>
{% if attendances %}
<table>
<thead>
<tr>
<th>{% translate "Date & Time" %}</th>
<th>{% translate "Description" %}</th>
<th>{% translate "Status" %}</th>
</tr>
</thead>
<tbody>
{% for att in attendances %}
<tr>
<td>
{% if att.slot.shift.start_time %}
{{ att.slot.shift.start_time|date:"d.m.Y H:i" }}
{% else %}
-
{% endif %}
</td>
<td>
{% if att.slot.shift %}
{{ att.slot.shift.title|default:att.slot.name|default:"(no description)" }}
{% else %}
{% translate "(Slot removed)" %}
{% endif %}
</td>
<td>{{ att.get_state_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">{% translate "No completed shifts found." %}</div>
{% endif %}
</div>
<div class="section">
<h2>{% translate "Shift account history" %} ({% blocktranslate count years=SHIFT_RETENTION_YEARS %}last year{% plural %}max. last {{ years }} years{% endblocktranslate %})</h2>
{% if attendances %}
<table>
<thead>
<tr>
<th>{% translate 'Date' %}</th>
<th>{% translate 'Value' %}</th>
<th>{% translate 'Balance at date' %}</th>
<th>{% translate 'Description' %}</th>
</tr>
</thead>
<tbody>
{% for entry_data in entries_data %}
<tr>
<td>{{ entry_data.entry.date|date:"d.m.Y H:i" }}</td>
<td>{{ entry_data.entry.value }}</td>
<td>{{ entry_data.balance_at_date }}</td>
<td class="description">{{ entry_data.entry.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">{% translate "No completed shifts found." %}</div>
{% endif %}
</div>
<div class="footer">{% translate "Generated on" %} {{ generated_at|date:"d.m.Y H:i" }}</div>
</body>
</html>
5 changes: 5 additions & 0 deletions tapir/coop/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,4 +255,9 @@
views.MemberSelfRegisterApiView.as_view(),
name="member_self_register",
),
path(
"member/<int:pk>/profile_pdf",
views.UserProfilePDFView.as_view(),
name="user_profile_pdf",
),
]
67 changes: 66 additions & 1 deletion tapir/coop/views/shareowner.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import django_filters
import django_tables2
import weasyprint
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required, permission_required
Expand All @@ -18,7 +20,7 @@
HttpResponseRedirect,
)
from django.shortcuts import get_object_or_404, redirect
from django.template import Context, Template
from django.template import Context, Template, loader
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
Expand Down Expand Up @@ -93,6 +95,7 @@
SHIFT_ATTENDANCE_MODE_CHOICES,
SHIFT_USER_CAPABILITY_CHOICES,
Shift,
ShiftAccountEntry,
ShiftExemption,
ShiftTemplateGroup,
)
Expand Down Expand Up @@ -1105,3 +1108,65 @@ def get_context_data(self, **kwargs):

def get_success_url(self):
return self.get_share_owner().get_absolute_url()


class UserProfilePDFView(
LoginRequiredMixin, PermissionRequiredMixin, generic.DetailView
):
model = ShareOwner
permission_required = PERMISSION_ACCOUNTS_MANAGE
pk_url_kwarg = "pk"
template_name = "coop/user_profile_pdf.html"

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
shareowner = self.object

attendances = []
if shareowner.pk and shareowner.user is not None:
cutoff_date = timezone.now() - relativedelta(
years=settings.SHIFT_RETENTION_YEARS
)

attendances = (
shareowner.user.shift_attendances.select_related("slot", "slot__shift")
.filter(slot__shift__start_time__gte=cutoff_date)
.order_by("slot__shift__start_time")
)
entries_data = [
{
"entry": entry,
"balance_at_date": shareowner.user.shift_user_data.get_account_balance(
at_date=entry.date
),
}
for entry in ShiftAccountEntry.objects.filter(
user=shareowner.user, date__gte=cutoff_date
).order_by("-date")
]

context.update(
{
"attendances": attendances,
"SHIFT_RETENTION_YEARS": settings.SHIFT_RETENTION_YEARS,
"shareowner": shareowner,
"generated_at": timezone.now(),
"entries_data": entries_data,
}
)
return context

def render_to_response(self, context, **response_kwargs):
request = self.request
html_string = loader.render_to_string(
self.template_name, context, request=request
)
html = weasyprint.HTML(
string=html_string, base_url=request.build_absolute_uri("/")
)
pdf = html.write_pdf(pdf_variant="pdf/a-1b")

response = HttpResponse(pdf, content_type="application/pdf")
filename = f"user-{self.object.pk}-profile.pdf"
response["Content-Disposition"] = f'inline; filename="{filename}"'
return response
3 changes: 3 additions & 0 deletions tapir/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,3 +438,6 @@
)

SESSION_COOKIE_AGE = 86400 # one day

# Duration of years, after which shifts are not relevant enough to be saved
SHIFT_RETENTION_YEARS = 8
Loading