invoicing: add basic appearance settings (#80893)
gitea/lingo/pipeline/head This commit looks good Details

This commit is contained in:
Frédéric Péters 2023-09-18 15:05:29 +02:00
parent 08aafb37a9
commit 2e024347f2
20 changed files with 583 additions and 136 deletions

View File

@ -0,0 +1,34 @@
from django.db import migrations, models
import lingo.utils.fields
class Migration(migrations.Migration):
dependencies = [
('invoicing', '0060_journal_line_booking'),
]
operations = [
migrations.CreateModel(
name='AppearanceSettings',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('logo', models.ImageField(blank=True, null=True, upload_to='logo', verbose_name='Logo')),
('address', lingo.utils.fields.RichTextField(blank=True, null=True, verbose_name='Address')),
(
'extra_info',
lingo.utils.fields.RichTextField(blank=True, null=True, verbose_name='Extra info'),
),
],
),
migrations.AddField(
model_name='regie',
name='invoice_custom_text',
field=lingo.utils.fields.RichTextField(
blank=True, null=True, verbose_name='Custom text in invoice'
),
),
]

View File

@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import copy
import datetime
import decimal
@ -28,6 +29,7 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.db import connection, models, transaction
from django.template import RequestContext, Template, TemplateSyntaxError, VariableDoesNotExist
from django.template.loader import get_template
from django.utils.encoding import force_str
from django.utils.formats import date_format
from django.utils.text import slugify
from django.utils.timezone import now
@ -35,6 +37,7 @@ from django.utils.translation import gettext_lazy as _
from lingo.agendas.chrono import ChronoError, lock_events_check
from lingo.agendas.models import Agenda
from lingo.utils.fields import RichTextField
from lingo.utils.misc import LingoImportError, clean_import_data, generate_slug
from lingo.utils.wcs import get_wcs_json, get_wcs_matching_card_model, get_wcs_services
@ -283,6 +286,11 @@ class Regie(models.Model):
default='R{regie_id:02d}-{yy}-{mm}-{number:07d}',
max_length=100,
)
invoice_custom_text = RichTextField(
_('Custom text in invoice'),
blank=True,
null=True,
)
class Meta:
ordering = ['label']
@ -656,6 +664,7 @@ class AbstractInvoice(models.Model):
'regie': self.regie,
'invoice': self,
'lines': self.get_grouped_and_ordered_lines(),
'appearance_settings': AppearanceSettings.singleton(),
}
return template.render(context)
@ -1110,3 +1119,29 @@ class InvoicePayment(models.Model):
max_digits=9, decimal_places=2, validators=[validators.MinValueValidator(decimal.Decimal('0.01'))]
)
created_at = models.DateTimeField(auto_now_add=True)
class AppearanceSettings(models.Model):
logo = models.ImageField(
verbose_name=_('Logo'),
upload_to='logo',
blank=True,
null=True,
)
address = RichTextField(
verbose_name=_('Address'),
blank=True,
null=True,
)
extra_info = RichTextField(
verbose_name=_('Extra info'),
blank=True,
null=True,
)
@classmethod
def singleton(cls):
return cls.objects.first() or cls()
def logo_base64_encoded(self):
return force_str(base64.encodebytes(self.logo.read()))

View File

@ -0,0 +1,28 @@
{% extends "lingo/invoicing/manager_regie_list.html" %}
{% load i18n static %}
{% block extrascripts %}
{{ block.super }}
{% include "lingo/includes/ckeditor-js.html" only %}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'lingo-manager-invoicing-appearance-settings' %}">{% trans "Appearance Settings" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Appearance" %}</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
</div>
</form>
{% endblock %}

View File

@ -6,47 +6,75 @@
<meta name="author" content="Entr'ouvert">
<style>
html {
padding: 0;
margin: 0;
color: #14213d;
font-family: Source Sans Pro;
font-size: 8pt;
font-family: "DejaVu Sans", sans-serif;
font-size: 9pt;
line-height: 1.2;
}
body {
margin: 0;
}
@media screen {
body {
margin: auto;
width: 21cm;
position: relative;
}
}
h1 {
color: #df5a13;
font-family: Pacifico;
margin: 0.4em 0;
font-size: 12pt;
}
h2 {
margin: 2em 0;
margin: 0.4em 0;
font-size: 10pt;
}
aside {
display: flex;
margin: 2em 0 4em;
}
aside address {
font-style: normal;
white-space: pre-line;
}
aside address#from {
color: #a9a;
flex: 1;
}
aside address#to {
text-align: right;
}
dl {
header {
/* top of page, addresses, can be folded */
position: absolute;
right: 0;
text-align: right;
top: 0;
left: 0;
width: 100%;
}
#logo {
max-height: 4cm;
max-width: 4cm;
}
main {
/* page content, after the fold */
padding-top: 9cm;
}
header aside {
position: absolute;
top: 0;
left: 0;
width: 30%;
}
header > div {
margin-left: 30%;
}
address {
font-style: normal;
}
address#to {
position: absolute;
box-sizing: border-box;
padding: 0.5cm;
top: 3cm; /* in addition to page margin (2cm) */
left: 8cm;
width: 10cm;
height: 5cm;
background: #fafafa;
}
#extra-info {
}
dl#informations {
margin-left: 30%; /* same as header/aside width */
margin-bottom: 1cm;
}
dt, dd {
display: inline;
@ -59,10 +87,6 @@
content: '';
display: block;
}
dt::after {
content: ':';
}
table {
border-collapse: collapse;
width: 100%;
@ -79,32 +103,36 @@
text-transform: uppercase;
}
td.total-amount {
color: #df5a13;
font-weight: bold;
}
tr.grand-total {
background: #eee;
border-top: 0.5cm solid white;
}
tr.grand-total td {
padding-top: 0.5cm;
padding-bottom: 0.5cm;
}
td.grand-total-intro {
text-align: right;
}
td.details {
vertical-align: top;
}
th.amount, td.amount, th.quantity, td.quantity {
text-align: right;
}
table#total {
background: #f6f6f6;
border-color: #f6f6f6;
border-style: solid;
border-width: 2cm 3cm;
bottom: 0;
right: 0;
font-size: 10pt;
margin: 0 -3cm;
position: absolute;
width: 12cm;
padding-right: 0.2cm;
text-align: right;
}
div.break {
break-before: page;
}
#regie-custom-text {
margin: auto;
margin-top: 2cm;
max-width: 55em;
}
@page {
margin: 1cm;
@bottom-right{
content: "{% trans "Page" %} " counter(page) "/" counter(pages);
}
@ -112,92 +140,109 @@
</style>
</head>
<body>
<h1>{{ invoice.label }}</h1>
<aside>
<address id="from">
{{ regie.label }}
</address>
<address id="to">
{{ invoice.payer_first_name}} {{ invoice.payer_last_name}}
</address>
</aside>
<dl id="informations">
<dt>{% trans "Invoice number" %}</dt>
<dd>{{ invoice.formatted_number }}</dd>
<dt>{% trans "Date" %}</dt>
<dd>{{ invoice.created_at|date }}</dd>
</dl>
{% for line in lines %}
{% ifchanged line.user_external_id %}
{% if not forloop.first %}
</tbody>
</table>
<header>
<aside>
{% if appearance_settings.logo %}
<img id="logo" src="data:{{appearance_settings.logo.content_type}};base64,{{ appearance_settings.logo_base64_encoded }}">
{% endif %}
<h2>{{ line.user_name }}</h2>
<table id="lines">
<thead>
<tr>
<th class="description">{% trans "Description" %}</th>
<th class="details">{% trans "Details" %}</th>
<th class="amount unit-amount">{% trans "UA" context 'unit amount' %}</th>
<th class="quantity">{% trans "QTY" context 'quantity' %}</th>
<th class="amount total-amount">{% trans "TA" context 'total amount' %}</th>
</tr>
</thead>
<tbody>
{% endifchanged %}
<tr {% if not line.details %}class="new-event"{% else %}{% ifchanged line.label %}class="new-event"{% endifchanged %}{% endif %}>
<td class="description">
{% if line.details %}
{% ifchanged line.user_external_id line.label %}
{{ line.label }}
{% if appearance_settings.address %}
<address id="from">
{{ appearance_settings.address|safe }}
</address>
{% endif %}
{% if appearance_settings.extra_info %}
<div id="extra-info">
{{ appearance_settings.extra_info|safe }}
</div>
{% endif %}
</aside>
<div>
<h1 id="invoice-label">{{ invoice.label }}</h1>
<h2 id="regie-label">{{ regie.label }}</h2>
<address id="to">
{{ invoice.payer_first_name}} {{ invoice.payer_last_name}}
</address>
</div>
</header>
<main>
<dl id="informations">
<dt>{% trans "Invoice number:" %}</dt>
<dd>{{ invoice.formatted_number }}</dd>
<dt>{% trans "Date:" %}</dt>
<dd>{{ invoice.created_at|date }}</dd>
</dl>
{% for line in lines %}
{% ifchanged line.user_external_id %}
{% if not forloop.first %}
</tbody>
</table>
{% endif %}
<h2 class="user-name">{{ line.user_name }}</h2>
<table id="lines">
<thead>
<tr>
<th class="description">{% trans "Description" %}</th>
<th class="details">{% trans "Details" %}</th>
<th class="amount unit-amount">{% trans "UA" context 'unit amount' %}</th>
<th class="quantity">{% trans "QTY" context 'quantity' %}</th>
<th class="amount total-amount">{% trans "TA" context 'total amount' %}</th>
</tr>
</thead>
<tbody>
{% endifchanged %}
<tr {% if not line.details %}class="new-event"{% else %}{% ifchanged line.label %}class="new-event"{% endifchanged %}{% endif %}>
<td class="description">
{% if line.details %}
{% ifchanged line.user_external_id line.label %}
{{ line.label }}
<br />
{% if line.agenda %}
<i>{{ line.agenda.label }}</i>
<br />
{% endif %}
{% endifchanged %}
{% else %}
{{ line.event_date|date:"d/m/Y" }} - {{ line.label }}
<br />
{% if line.agenda %}
<i>{{ line.agenda.label }}</i>
<br />
{% endif %}
{% endifchanged %}
{% else %}
{{ line.event_date|date:"d/m/Y" }} - {{ line.label }}
<br />
{% if line.agenda %}
<i>{{ line.agenda.label }}</i>
<br />
{% endif %}
{% if line.details.check_type_label %}
{{ line.details.check_type_label }}
{% elif line.details.status == 'absence' %}
{% trans "Absence" %}
{% endif %}
</td>
<td class="details">
{% if line.details.dates and not line.agenda.partial_bookings %}
{% for d in line.details.dates %}{{ d|parse_date|date:"Dd" }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% endif %}
</td>
{% if line.details.check_type_label or line.details.status != 'absence' %}
<td class="amount unit-amount">{% blocktrans with amount=line.unit_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
<td class="quantity">{{ line.quantity|floatformat }}</td>
<td class="amount total-amount">{% blocktrans with amount=line.total_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
{% endif %}
{% if line.details.check_type_label %}
{{ line.details.check_type_label }}
{% elif line.details.status == 'absence' %}
{% trans "Absence" %}
{% endif %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="grand-total">
<td class="grand-total-intro" colspan="4">
{% trans "Total amount to be paid before:" %} <strong id="payment-deadline">{{ invoice.date_payment_deadline }}</strong>
</td>
<td class="details">
{% if line.details.dates and not line.agenda.partial_bookings %}
{% for d in line.details.dates %}{{ d|parse_date|date:"Dd" }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% endif %}
</td>
{% if line.details.check_type_label or line.details.status != 'absence' %}
<td class="amount unit-amount">{% blocktrans with amount=line.unit_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
<td class="quantity">{{ line.quantity|floatformat }}</td>
<td class="amount total-amount">{% blocktrans with amount=line.total_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
{% endif %}
<td class="amount total-amount">{% blocktrans with amount=invoice.total_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<table id="total">
<thead>
<tr>
<th>{% trans "Payment deadline" %}</th>
<th>{% trans "Total due" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ invoice.date_payment_deadline }}</td>
<td>{% blocktrans with amount=invoice.total_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
</tr>
</tbody>
</table>
</tfoot>
</table>
{% if regie.invoice_custom_text %}
<div id="regie-custom-text">
{{ regie.invoice_custom_text|safe }}
</div>
{% endif %}
</main>
</body>
</html>

View File

@ -0,0 +1,33 @@
{% extends "lingo/invoicing/manager_regie_list.html" %}
{% load i18n gadjo %}
{% block extrascripts %}
{{ block.super }}
{% include "lingo/includes/ckeditor-js.html" only %}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'lingo-manager-invoicing-regie-detail' regie.pk %}">{{ regie }}</a>
<a href="{% url 'lingo-manager-invoicing-regie-invoices-edit' regie.pk %}">{% trans "Invoices Settings" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Invoices Settings" %}</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button>{% trans "Submit" %}</button>
{% if object.pk %}
<a class="cancel" href="{% url 'lingo-manager-invoicing-regie-detail' regie.pk %}">{% trans 'Cancel' %}</a>
{% else %}
<a class="cancel" href="{% url 'lingo-manager-invoicing-regie-list' %}">{% trans 'Cancel' %}</a>
{% endif %}
</div>
</form>
{% endblock %}

View File

@ -14,6 +14,7 @@
<li><a rel="popup" href="{% url 'lingo-manager-invoicing-config-import' %}">{% trans 'Import' %}</a></li>
<li><a rel="popup" href="{% url 'lingo-manager-invoicing-config-export' %}" data-autoclose-dialog="true">{% trans 'Export' %}</a></li>
<li><a href="{% url 'lingo-manager-invoicing-payer-list' %}">{% trans "Payers" %}</a></li>
<li><a href="{% url 'lingo-manager-invoicing-appearance-settings' %}">{% trans "Appearance Settings" %}</a></li>
</ul>
<a rel="popup" href="{% url 'lingo-manager-invoicing-regie-add' %}">{% trans 'New regie' %}</a>
</span>

View File

@ -28,6 +28,7 @@
<button aria-controls="panel-counters" aria-selected="false" id="tab-counters" role="tab" tabindex="-1">{% trans "Counters" %}</button>
<button aria-controls="panel-usage" aria-selected="false" id="tab-usage" role="tab" tabindex="-1">{% trans "Used in agendas" %}</button>
<button aria-controls="panel-payment-types" aria-selected="false" id="tab-payment-types" role="tab" tabindex="-1">{% trans "Payment types" %}</button>
<button aria-controls="panel-invoices" aria-selected="false" id="tab-invoices" role="tab" tabindex="-1">{% trans "Invoices" %}</button>
</div>
<div class="pk-tabs--container">
@ -95,6 +96,16 @@
</div>
</div>
<div aria-labelledby="tab-invoices" hidden="" id="panel-invoices" role="tabpanel" tabindex="0">
<dl>
<dt><b>{% trans "Custom text:" %}</b></dt>
<dd><div>{{ regie.invoice_custom_text|default:"-"|safe }}</div></dd>
</dl>
<div class="panel--buttons">
<a class="pk-button" href="{% url 'lingo-manager-invoicing-regie-invoices-edit' pk=regie.pk %}">{% trans "Edit" %}</a>
</div>
</div>
</div>
</div>
</div>

View File

@ -16,6 +16,7 @@
from django.urls import path
from .views import appearance as appearance_views
from .views import campaign as campaign_views
from .views import home as home_views
from .views import payer as payer_views
@ -26,6 +27,11 @@ urlpatterns = [
path('import/', home_views.config_import, name='lingo-manager-invoicing-config-import'),
path('export/', home_views.config_export, name='lingo-manager-invoicing-config-export'),
path('regies/', regie_views.regies_list, name='lingo-manager-invoicing-regie-list'),
path(
'appearance/',
appearance_views.appearance_settings,
name='lingo-manager-invoicing-appearance-settings',
),
path(
'regie/add/',
regie_views.regie_add,
@ -51,6 +57,11 @@ urlpatterns = [
regie_views.regie_counters_edit,
name='lingo-manager-invoicing-regie-counters-edit',
),
path(
'regie/<int:pk>/edit/invoices/',
regie_views.regie_invoices_edit,
name='lingo-manager-invoicing-regie-invoices-edit',
),
path(
'regie/<int:pk>/delete/',
regie_views.regie_delete,

View File

@ -0,0 +1,33 @@
# lingo - payment and billing system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.urls import reverse_lazy
from django.views.generic import UpdateView
from lingo.invoicing.models import AppearanceSettings
class AppearanceSettingsView(UpdateView):
model = AppearanceSettings
success_url = reverse_lazy('lingo-manager-invoicing-regie-list')
template_name = 'lingo/invoicing/appearance_settings_form.html'
fields = '__all__'
def get_object(self):
return AppearanceSettings.singleton()
appearance_settings = AppearanceSettingsView.as_view()

View File

@ -143,6 +143,20 @@ class RegieCountersEditView(UpdateView):
regie_counters_edit = RegieCountersEditView.as_view()
class RegieInvoicesEditView(UpdateView):
template_name = 'lingo/invoicing/manager_regie_invoices_form.html'
model = Regie
fields = [
'invoice_custom_text',
]
def get_success_url(self):
return '%s#open:invoices' % reverse('lingo-manager-invoicing-regie-parameters', args=[self.object.pk])
regie_invoices_edit = RegieInvoicesEditView.as_view()
class RegieDeleteView(DeleteView):
template_name = 'lingo/manager_confirm_delete.html'
model = Regie

View File

@ -33,5 +33,6 @@ class PDFMixin:
html = HTML(string=result)
pdf = html.write_pdf()
response = HttpResponse(pdf, content_type='application/pdf')
response['Content-Disposition'] = 'attachment; filename="%s.pdf"' % self.get_filename()
if 'inline' not in request.GET:
response['Content-Disposition'] = 'attachment; filename="%s.pdf"' % self.get_filename()
return response

View File

@ -152,3 +152,8 @@ table.invoicing-element-list {
ul.objects-list li span.badge.badge-success {
background: var(--green);
}
.django-ckeditor-widget {
display: block !important;
max-width: 80em;
}

57
lingo/monkeypatch.py Normal file
View File

@ -0,0 +1,57 @@
# lingo - payment and billing system
# Copyright (C) 2022-2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import ckeditor.views
import ckeditor.widgets
from django.forms.utils import flatatt
from django.template.loader import render_to_string
from django.utils.encoding import force_str
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe
from django.utils.translation import get_language
def ckeditor_render(self, name, value, attrs=None, renderer=None):
if value is None:
value = ''
final_attrs = {'name': name}
if getattr(self, 'attrs', None):
final_attrs.update(self.attrs)
if attrs:
final_attrs.update(attrs)
if not self.config.get('language'):
self.config['language'] = get_language()
# Force to text to evaluate possible lazy objects
external_plugin_resources = [
[force_str(a), force_str(b), force_str(c)] for a, b, c in self.external_plugin_resources
]
return mark_safe(
render_to_string(
'ckeditor/widget.html',
{
'final_attrs': flatatt(final_attrs),
'value': conditional_escape(force_str(value)),
'id': final_attrs['id'],
'config': ckeditor.widgets.json_encode(self.config),
'external_plugin_resources': ckeditor.widgets.json_encode(external_plugin_resources),
},
)
)
ckeditor.widgets.CKEditorWidget.render = ckeditor_render

View File

@ -52,6 +52,7 @@ INSTALLED_APPS = (
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
'ckeditor',
'eopayment',
'gadjo',
'rest_framework',
@ -202,6 +203,29 @@ DEBUG_TOOLBAR_CONFIG = {'SHOW_TOOLBAR_CALLBACK': debug_show_toolbar}
REST_FRAMEWORK = {'EXCEPTION_HANDLER': 'lingo.api.utils.exception_handler'}
CKEDITOR_UPLOAD_PATH = 'uploads/'
CKEDITOR_IMAGE_BACKEND = 'pillow'
CKEDITOR_CONFIGS = {
'default': {
'allowedContent': False,
'extraPlugins': 'stylescombo',
'removePlugins': 'stylesheetparser',
'toolbar_Own': [
['Styles'],
['Bold', 'Italic'],
['NumberedList', 'BulletedList'],
['JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock'],
['Link', 'Unlink'],
['HorizontalRule'],
],
'toolbar': 'Own',
'resize_enabled': False,
'width': '100%',
'height': 150,
},
}
local_settings_file = os.environ.get(
'LINGO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
)

View File

@ -0,0 +1,12 @@
{% load i18n static %}
<script src="{% static "ckeditor/ckeditor/ckeditor.js" %}"></script>
<script type="text/javascript" src="{% static "ckeditor/ckeditor-init.js" %}"></script>
<script>
CKEDITOR.stylesSet.add('default',
[
{name: '{% trans "Paragraph" %}', element: 'p', styles: {'font-size': 'unset'}},
{name: '{% trans "Small paragraph" %}', element: 'p', styles: {'font-size': '80%'}},
]
)
</script>

48
lingo/utils/fields.py Normal file
View File

@ -0,0 +1,48 @@
# lingo - payment and billing system
# Copyright (C) 2022-2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import ckeditor.fields
from django.conf import settings
import lingo.monkeypatch # noqa pylint: disable=unused-import
class RichTextField(ckeditor.fields.RichTextField):
def formfield(self, **kwargs):
defaults = {
'form_class': RichTextFormField,
'config_name': self.config_name,
'extra_plugins': self.extra_plugins,
'external_plugin_resources': self.external_plugin_resources,
}
defaults.update(kwargs)
return super().formfield(**defaults)
class RichTextFormField(ckeditor.fields.RichTextFormField):
def clean(self, value):
value = super().clean(value)
if settings.LANGUAGE_CODE.startswith('fr-'):
# apply some typographic rules
value = value.replace('&laquo; ', '«\u202f')
value = value.replace('« ', '«\u202f')
value = value.replace(' &raquo;', '\u202f»')
value = value.replace(' »', '\u202f»')
value = value.replace(' :', '\u00a0:')
value = value.replace(' ;', '\u202f;')
value = value.replace(' !', '\u202f!')
value = value.replace(' ?', '\u202f?')
return value

View File

@ -161,6 +161,7 @@ setup(
],
install_requires=[
'django>=3.2, <3.3',
'django-ckeditor<4.5.4',
'gadjo>=0.53',
'requests',
'eopayment>=1.60',

View File

@ -1993,23 +1993,23 @@ def test_invoice_pdf(app, admin_user, draft):
'/manage/invoicing/regie/%s/campaign/%s/pool/%s/invoice/%s/pdf/?html'
% (regie.pk, campaign.pk, pool.pk, invoice.pk)
)
assert resp.pyquery('h1').text() == 'Invoice from 01/09/2022 to 30/09/2022'
assert resp.pyquery('address#from').text() == 'Foo'
assert resp.pyquery('#invoice-label').text() == 'Invoice from 01/09/2022 to 30/09/2022'
assert resp.pyquery('#regie-label').text() == 'Foo'
assert resp.pyquery('address#to').text() == 'First1 Name1'
if draft:
assert resp.pyquery('dl#informations').text() == 'Invoice number\nTEMPORARY-%s\nDate\n%s' % (
assert resp.pyquery('dl#informations').text() == 'Invoice number:\nTEMPORARY-%s\nDate:\n%s' % (
invoice.pk,
date_format(localtime(invoice.created_at), 'DATE_FORMAT'),
)
else:
assert resp.pyquery('dl#informations').text() == 'Invoice number\nF%02d-%s-0000001\nDate\n%s' % (
assert resp.pyquery('dl#informations').text() == 'Invoice number:\nF%02d-%s-0000001\nDate:\n%s' % (
regie.pk,
invoice.created_at.strftime('%y-%m'),
date_format(localtime(invoice.created_at), 'DATE_FORMAT'),
)
assert len(resp.pyquery('h2 + table#lines')) == 2
assert resp.pyquery('h2')[0].text == 'User1 Name1'
assert resp.pyquery('h2')[1].text == 'User2 Name2'
assert len(resp.pyquery('h2.user-name + table#lines')) == 2
assert resp.pyquery('h2.user-name')[0].text == 'User1 Name1'
assert resp.pyquery('h2.user-name')[1].text == 'User2 Name2'
assert len(resp.pyquery('table#lines')) == 2
tables = resp.pyquery('table#lines')
assert [PyQuery(tr).text() for tr in PyQuery(tables).find('thead tr')] == [
@ -2023,7 +2023,8 @@ def test_invoice_pdf(app, admin_user, draft):
assert [PyQuery(tr).text() for tr in PyQuery(tables[1]).find('tbody tr')] == [
'02/09/2022 - Label 12\n\n2.00€\n1\n2.00€',
]
assert resp.pyquery('table#total tbody').text() == 'Oct. 31, 2022\n6.20€'
assert resp.pyquery('#payment-deadline').text() == 'Oct. 31, 2022'
assert resp.pyquery('tfoot .total-amount').text() == '6.20€'
resp = app.get(
'/manage/invoicing/regie/%s/campaign/%s/pool/%s/invoice/%s/pdf/?html'

View File

@ -1,3 +1,4 @@
import base64
import datetime
import decimal
from urllib.parse import urlparse
@ -8,10 +9,12 @@ from django.urls import reverse
from django.utils.formats import date_format
from django.utils.timezone import localtime, now
from pyquery import PyQuery
from webtest import Upload
from lingo.agendas.models import Agenda
from lingo.invoicing.models import (
DEFAULT_PAYMENT_TYPES,
AppearanceSettings,
Campaign,
Counter,
DraftJournalLine,
@ -217,6 +220,45 @@ def test_manager_invoicing_regie_counters_edit(app, admin_user):
assert resp.location.endswith('/manage/invoicing/regie/%s/parameters/#open:counters' % regie.pk)
def test_manager_invoicing_appearance_settings(app, admin_user, settings):
app = login(app)
regie = Regie.objects.create(label='Foo', description='foo description')
resp = app.get(reverse('lingo-manager-invoicing-regie-list'))
resp = resp.click('Appearance Settings')
resp.form['logo'] = Upload(
'test.png',
base64.decodebytes(
b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVQI12NoAAAAggCB3UNq9AAAAABJRU5ErkJggg=='
),
'image/png',
)
resp.form['address'] = '<p>Foo bar<br>Streetname</p>'
resp.form['extra_info'] = '<p>Opening hours...</p>'
resp = resp.form.submit('submit').follow()
appearance_settings = AppearanceSettings.singleton()
assert appearance_settings.logo.name == 'logo/test.png'
assert appearance_settings.address == '<p>Foo bar<br>Streetname</p>'
assert appearance_settings.extra_info == '<p>Opening hours...</p>'
resp = resp.click('Appearance Settings')
assert resp.form['address'].value == '<p>Foo bar<br>Streetname</p>'
# regie settings
resp = app.get(reverse('lingo-manager-invoicing-regie-invoices-edit', kwargs={'pk': regie.pk}))
resp.form.set('invoice_custom_text', '<p>custom text</p>')
resp = resp.form.submit()
regie.refresh_from_db()
assert regie.invoice_custom_text == '<p>custom text</p>'
assert resp.location.endswith('/manage/invoicing/regie/%s/parameters/#open:invoices' % regie.pk)
# check French typography fixes
settings.LANGUAGE_CODE = 'fr-fr'
resp = app.get(reverse('lingo-manager-invoicing-regie-invoices-edit', kwargs={'pk': regie.pk}))
resp.form.set('invoice_custom_text', '<p>custom : text</p>')
resp = resp.form.submit()
regie.refresh_from_db()
assert regie.invoice_custom_text == '<p>custom\u00a0: text</p>'
def test_manager_invoicing_regie_delete(app, admin_user):
app = login(app)
group = Group.objects.create(name='role-foo')
@ -1166,17 +1208,17 @@ def test_regie_invoice_pdf(app, admin_user):
app = login(app)
resp = app.get('/manage/invoicing/regie/%s/invoice/%s/pdf/?html' % (regie.pk, invoice.pk))
assert resp.pyquery('h1').text() == 'Invoice from 01/09/2022 to 30/09/2022'
assert resp.pyquery('address#from').text() == 'Foo'
assert resp.pyquery('#invoice-label').text() == 'Invoice from 01/09/2022 to 30/09/2022'
assert resp.pyquery('#regie-label').text() == 'Foo'
assert resp.pyquery('address#to').text() == 'First1 Name1'
assert resp.pyquery('dl#informations').text() == 'Invoice number\nF%02d-%s-0000001\nDate\n%s' % (
assert resp.pyquery('dl#informations').text() == 'Invoice number:\nF%02d-%s-0000001\nDate:\n%s' % (
regie.pk,
invoice.created_at.strftime('%y-%m'),
date_format(localtime(invoice.created_at), 'DATE_FORMAT'),
)
assert len(resp.pyquery('h2 + table#lines')) == 2
assert resp.pyquery('h2')[0].text == 'User1 Name1'
assert resp.pyquery('h2')[1].text == 'User2 Name2'
assert len(resp.pyquery('h2.user-name + table#lines')) == 2
assert resp.pyquery('h2.user-name')[0].text == 'User1 Name1'
assert resp.pyquery('h2.user-name')[1].text == 'User2 Name2'
assert len(resp.pyquery('table#lines')) == 2
tables = resp.pyquery('table#lines')
assert [PyQuery(tr).text() for tr in PyQuery(tables).find('thead tr')] == [
@ -1190,7 +1232,8 @@ def test_regie_invoice_pdf(app, admin_user):
assert [PyQuery(tr).text() for tr in PyQuery(tables[1]).find('tbody tr')] == [
'02/09/2022 - Label 12\n\n2.00€\n1\n2.00€',
]
assert resp.pyquery('table#total tbody').text() == 'Oct. 31, 2022\n6.20€'
assert resp.pyquery('#payment-deadline').text() == 'Oct. 31, 2022'
assert resp.pyquery('tfoot .total-amount').text() == '6.20€'
resp = app.get('/manage/invoicing/regie/%s//invoice/%s/pdf/?html' % (0, invoice.pk), status=404)
resp = app.get('/manage/invoicing/regie/%s//invoice/%s/pdf/?html' % (regie.pk, 0), status=404)
@ -1199,6 +1242,14 @@ def test_regie_invoice_pdf(app, admin_user):
'/manage/invoicing/regie/%s/invoice/%s/pdf/?html' % (other_regie.pk, invoice.pk), status=404
)
appearance_settings = AppearanceSettings.singleton()
appearance_settings.address = '<p>Foo bar<br>Streetname</p>'
appearance_settings.extra_info = '<p>Opening hours...</p>'
appearance_settings.save()
resp = app.get('/manage/invoicing/regie/%s/invoice/%s/pdf/?html' % (regie.pk, invoice.pk))
assert appearance_settings.address in resp.text
assert appearance_settings.extra_info in resp.text
def test_regie_invoice_payments_pdf(app, admin_user):
regie = Regie.objects.create(label='Foo')

View File

@ -32,6 +32,7 @@ deps =
django-mellon>=1.13
django-filter>=2.4,<2.5
git+https://git.entrouvert.org/publik-django-templatetags.git
git+https://git.entrouvert.org/entrouvert/debian-django-ckeditor.git
pre-commit
commands =
./getlasso3.sh
@ -59,6 +60,7 @@ deps =
pyquery
psycopg2-binary
git+https://git.entrouvert.org/publik-django-templatetags.git
git+https://git.entrouvert.org/entrouvert/debian-django-ckeditor.git
commands =
./getlasso3.sh
./pylint.sh lingo/