invoicing: inject line model (#73456) #16

Merged
lguerin merged 2 commits from wip/73456-invoicing-injected-line into main 2023-01-20 16:47:56 +01:00
10 changed files with 398 additions and 27 deletions

View File

@ -22,6 +22,7 @@ from rest_framework.exceptions import ValidationError
from lingo.agendas.chrono import ChronoError, get_events
from lingo.agendas.models import Agenda
from lingo.invoicing.models import InjectedLine, Regie
from lingo.pricing.models import AgendaPricing, AgendaPricingNotFound, PricingError
@ -234,3 +235,21 @@ class PricingComputeSerializer(serializers.Serializer):
result['error'] = type(e).__name__
result['error_details'] = e.details
return result
class InjectedLineSerializer(serializers.ModelSerializer):
regie = serializers.SlugRelatedField(queryset=Regie.objects, slug_field='slug')
class Meta:
model = InjectedLine
fields = [
'event_date',
'slug',
'label',
'quantity',
'unit_amount',
'total_amount',
'user_external_id',
'payer_external_id',
'regie',
]

View File

@ -34,4 +34,9 @@ urlpatterns = [
views.invoicing_regies,
name='api-invoicing-regies',
),
path(
'invoicing/injected-lines/',
views.injected_lines,
name='api-invoicing-injected-lines',
),
]

View File

@ -22,7 +22,7 @@ from rest_framework.views import APIView
from lingo.agendas.models import Agenda
from lingo.api import serializers
from lingo.api.utils import APIErrorBadRequest, Response
from lingo.invoicing.models import Regie
from lingo.invoicing.models import InjectedLine, Regie
class AgendaCheckTypeList(APIView):
@ -73,3 +73,19 @@ class InvoicingRegies(APIView):
invoicing_regies = InvoicingRegies.as_view()
class InjectedLines(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.InjectedLineSerializer
def post(self, request):
serializer = self.serializer_class(data=request.data)
if not serializer.is_valid():
lguerin marked this conversation as resolved Outdated
Outdated
Review

oups ?

oups ?
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
instance = InjectedLine.objects.create(**serializer.validated_data)
return Response({'err': 0, 'id': instance.pk})
injected_lines = InjectedLines.as_view()

View File

@ -0,0 +1,47 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoicing', '0007_pool_exception'),
]
operations = [
migrations.CreateModel(
name='InjectedLine',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('event_date', models.DateField()),
('slug', models.SlugField(max_length=250)),
('label', models.CharField(max_length=260)),
('quantity', models.FloatField()),
('unit_amount', models.DecimalField(decimal_places=2, max_digits=9)),
('total_amount', models.DecimalField(decimal_places=2, max_digits=9)),
('user_external_id', models.CharField(max_length=250)),
('payer_external_id', models.CharField(max_length=250)),
(
'regie',
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='invoicing.Regie'),
),
],
),
migrations.AddField(
model_name='draftinvoiceline',
name='from_injected_line',
field=models.ForeignKey(
null=True, on_delete=django.db.models.deletion.PROTECT, to='invoicing.InjectedLine'
),
),
migrations.AddField(
model_name='invoiceline',
name='from_injected_line',
field=models.ForeignKey(
null=True, on_delete=django.db.models.deletion.PROTECT, to='invoicing.InjectedLine'
),
),
]

View File

@ -194,6 +194,19 @@ class Invoice(AbstractInvoice):
pass
class InjectedLine(models.Model):
event_date = models.DateField()
slug = models.SlugField(max_length=250)
label = models.CharField(max_length=260)
quantity = models.FloatField()
unit_amount = models.DecimalField(max_digits=9, decimal_places=2)
total_amount = models.DecimalField(max_digits=9, decimal_places=2)
user_external_id = models.CharField(max_length=250)
payer_external_id = models.CharField(max_length=250)
regie = models.ForeignKey(Regie, on_delete=models.PROTECT)
class AbstractInvoiceLine(models.Model):
slug = models.SlugField(max_length=250)
label = models.CharField(max_length=260)
@ -274,7 +287,9 @@ class AbstractInvoiceLine(models.Model):
class DraftInvoiceLine(AbstractInvoiceLine):
invoice = models.ForeignKey(DraftInvoice, on_delete=models.PROTECT, null=True, related_name='lines')
from_injected_line = models.ForeignKey(InjectedLine, on_delete=models.PROTECT, null=True)
class InvoiceLine(AbstractInvoiceLine):
invoice = models.ForeignKey(Invoice, on_delete=models.PROTECT, null=True, related_name='lines')
from_injected_line = models.ForeignKey(InjectedLine, on_delete=models.PROTECT, null=True)

View File

@ -33,7 +33,7 @@
<th>{% trans "PK" %}</th>
<th>{% trans "Invoice PK" %}</th>
<th>{% trans "Label" %}</th>
<th>{% trans "Event" %}</th>
<th>{% trans "Slug" %}</th>
Ghost marked this conversation as resolved
Review

ça fait partie du même travail, le remplacement cette colonne "Event" par une colonne "Slug" ? (j'ai l'impression de rater un truc évident, mais)

ça fait partie du même travail, le remplacement cette colonne "Event" par une colonne "Slug" ? (j'ai l'impression de rater un truc évident, mais)
Review

J'allais chercher le slug de l'event dans line.event.xxx, mais j'ai la même chose dans line.slug, et pour une ligne injectée je veux voir line.slug.

J'allais chercher le slug de l'event dans line.event.xxx, mais j'ai la même chose dans line.slug, et pour une ligne injectée je veux voir line.slug.
<th>{% trans "Quantity" %}</th>
<th>{% trans "Unit amount" %}</th>
<th>{% trans "Total amount" %}</th>
@ -49,7 +49,7 @@
<td class="line_id">{{ line.pk }}</td>
<td>{{ line.invoice_id|default:'' }}</td>
<td>{{ line.label }}</td>
<td>{{ line.event.slug }}</td>
<td>{{ line.slug }}</td>
<td>{{ line.quantity }}</td>
<td>{{ line.unit_amount }}</td>
<td>{{ line.total_amount }}</td>
@ -58,6 +58,7 @@
<td class="status">
<span class="tag tag-{{ line.status }}">{{ line.get_status_display }}</span>
{% if line.status != 'success' %}({{ line.get_error_display }}){% endif %}
{% if line.from_injected_line_id %}({% trans "Injected" %}){% endif %}
</td>
<td><a class="details-toggle">{% trans "see details" %}</a></td>
</tr>

View File

@ -22,7 +22,7 @@ from django.utils.translation import ugettext_lazy as _
from lingo.agendas.chrono import get_check_status, get_subscriptions
from lingo.agendas.models import Agenda
from lingo.invoicing.models import DraftInvoice, DraftInvoiceLine, RegieNotConfigured
from lingo.invoicing.models import DraftInvoice, DraftInvoiceLine, InjectedLine, RegieNotConfigured
from lingo.pricing.models import AgendaPricing, AgendaPricingNotFound, PricingError
@ -134,7 +134,44 @@ def get_invoice_lines_for_user(agendas, agendas_pricings, user_external_id, pool
pool=pool,
)
)
# fetch injected lines
injected_lines = (
InjectedLine.objects.filter(
event_date__gte=pool.campaign.date_start,
event_date__lt=pool.campaign.date_end,
user_external_id=user_external_id,
)
.exclude(
# exclude already invoiced lines
invoiceline__isnull=False
)
.exclude(
# exclude lines used in another campaign
pk__in=DraftInvoiceLine.objects.filter(from_injected_line__isnull=False)
.exclude(pool__campaign=pool.campaign)
.values('from_injected_line')
)
)
for injected_line in injected_lines:
lines.append(
DraftInvoiceLine(
slug=injected_line.slug,
label=injected_line.label,
quantity=injected_line.quantity,
unit_amount=injected_line.unit_amount,
total_amount=injected_line.total_amount,
user_external_id=injected_line.user_external_id,
payer_external_id=injected_line.user_external_id,
status='success',
pool=pool,
from_injected_line=injected_line,
)
)
DraftInvoiceLine.objects.bulk_create(lines)
return lines
@ -169,13 +206,16 @@ def generate_invoices_from_lines(agendas, all_lines, pool):
if line.status != 'success':
# ignore lines in error
continue
agenda_slug = line.event['agenda']
if agenda_slug not in agendas_by_slug:
# should not happen
continue
regie = agendas_by_slug[agenda_slug].regie
if not regie:
raise RegieNotConfigured(_('Regie not configured on %s') % agenda_slug)
if line.from_injected_line:
regie = line.from_injected_line.regie
else:
agenda_slug = line.event['agenda']
if agenda_slug not in agendas_by_slug:
# should not happen
continue
regie = agendas_by_slug[agenda_slug].regie
if not regie:
raise RegieNotConfigured(_('Regie not configured on %s') % agenda_slug)
if regie.pk not in lines_by_regie:
lines_by_regie[regie.pk] = {}
regie_subs = lines_by_regie[regie.pk]

View File

@ -1,6 +1,8 @@
import datetime
import pytest
from lingo.invoicing.models import Regie
from lingo.invoicing.models import InjectedLine, Regie
pytestmark = pytest.mark.django_db
@ -22,3 +24,51 @@ def test_regies(app, user):
{'id': 'bar', 'text': 'Bar', 'slug': 'bar'},
{'id': 'foo', 'text': 'Foo', 'slug': 'foo'},
]
def test_add_injected_line(app, user):
app.post('/api/invoicing/injected-lines/', status=403)
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post('/api/invoicing/injected-lines/', status=400)
assert resp.json['err']
assert resp.json['errors'] == {
'event_date': ['This field is required.'],
'slug': ['This field is required.'],
'label': ['This field is required.'],
'quantity': ['This field is required.'],
'unit_amount': ['This field is required.'],
'total_amount': ['This field is required.'],
'user_external_id': ['This field is required.'],
'payer_external_id': ['This field is required.'],
'regie': ['This field is required.'],
}
params = {
'event_date': '2023-01-17',
'slug': 'foobar',
'label': 'Foo Bar',
'quantity': 42,
'unit_amount': 2,
'total_amount': 64,
'user_external_id': 'user:1',
'payer_external_id': 'payer:1',
'regie': 'foo',
}
resp = app.post('/api/invoicing/injected-lines/', params=params, status=400)
assert resp.json['err']
assert resp.json['errors'] == {'regie': ['Object with slug=foo does not exist.']}
regie = Regie.objects.create(slug='foo')
resp = app.post('/api/invoicing/injected-lines/', params=params)
assert resp.json['err'] == 0
injected_line = InjectedLine.objects.get(pk=resp.json['id'])
assert injected_line.event_date == datetime.date(2023, 1, 17)
assert injected_line.slug == 'foobar'
assert injected_line.label == 'Foo Bar'
assert injected_line.quantity == 42
assert injected_line.unit_amount == 2
assert injected_line.total_amount == 64
assert injected_line.user_external_id == 'user:1'
assert injected_line.payer_external_id == 'payer:1'
assert injected_line.regie == regie

View File

@ -10,7 +10,16 @@ from django.test.utils import CaptureQueriesContext
from lingo.agendas.chrono import ChronoError
from lingo.agendas.models import Agenda
from lingo.invoicing import utils
from lingo.invoicing.models import Campaign, DraftInvoice, DraftInvoiceLine, Pool, Regie, RegieNotConfigured
from lingo.invoicing.models import (
Campaign,
DraftInvoice,
DraftInvoiceLine,
InjectedLine,
InvoiceLine,
Pool,
Regie,
RegieNotConfigured,
)
from lingo.pricing.models import AgendaPricing, Criteria, CriteriaCategory, Pricing, PricingError
pytestmark = pytest.mark.django_db
@ -200,6 +209,7 @@ def test_get_invoice_lines_for_user_check_status_error(mock_status):
@mock.patch('lingo.invoicing.utils.get_check_status')
@mock.patch('lingo.pricing.models.AgendaPricing.get_pricing_data_for_event')
def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_status):
regie = Regie.objects.create(label='Regie')
agenda1 = Agenda.objects.create(label='Agenda 1')
agenda2 = Agenda.objects.create(label='Agenda 2')
pricing = Pricing.objects.create(label='Foo bar 1')
@ -214,10 +224,126 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s
date_end=datetime.date(2022, 10, 1),
date_issue=datetime.date(2022, 10, 31),
)
old_pool = Pool.objects.create(
campaign=campaign,
draft=True,
)
pool = Pool.objects.create(
campaign=campaign,
draft=True,
)
other_campaign = Campaign.objects.create(
date_start=datetime.date(2022, 9, 1),
date_end=datetime.date(2022, 10, 1),
date_issue=datetime.date(2022, 10, 31),
)
other_pool = Pool.objects.create(
campaign=other_campaign,
draft=True,
)
# create some injected lines
InjectedLine.objects.create(
event_date=datetime.date(2022, 8, 31), # too soon
slug='event-2022-08-31',
label='Event 2022-08-31',
quantity=2,
unit_amount=1.5,
total_amount=3,
user_external_id='user:1',
payer_external_id='payer:1',
regie=regie,
)
injected_line2 = InjectedLine.objects.create(
event_date=datetime.date(2022, 9, 1),
slug='event-2022-09-01',
label='Event 2022-09-01',
quantity=2,
unit_amount=1.5,
total_amount=3,
user_external_id='user:1',
payer_external_id='payer:1',
regie=regie,
)
# ok, same campaign
DraftInvoiceLine.objects.create(
quantity=0,
unit_amount=0,
total_amount=0,
pool=old_pool,
from_injected_line=injected_line2,
)
InjectedLine.objects.create(
event_date=datetime.date(2022, 9, 2),
slug='event-2022-09-02',
label='Event 2022-09-02',
quantity=2,
unit_amount=1.5,
total_amount=3,
user_external_id='user:2', # wrong user
payer_external_id='payer:1',
regie=regie,
)
injected_line4 = InjectedLine.objects.create(
event_date=datetime.date(2022, 9, 30),
slug='event-2022-09-30',
label='Event 2022-09-30',
quantity=3,
unit_amount=1.5,
total_amount=4.5,
user_external_id='user:1',
payer_external_id='payer:1',
regie=regie,
)
InjectedLine.objects.create(
event_date=datetime.date(2022, 10, 1), # too late
slug='event-2022-10-01',
label='Event 2022-10-01',
quantity=2,
unit_amount=1.5,
total_amount=3,
user_external_id='user:1',
payer_external_id='payer:1',
regie=regie,
)
injected_line6 = InjectedLine.objects.create(
event_date=datetime.date(2022, 9, 15),
slug='event-2022-09-15',
label='Event 2022-09-15',
quantity=3,
unit_amount=1.5,
total_amount=4.5,
user_external_id='user:1',
payer_external_id='payer:1',
regie=regie,
)
# nok, already invoiced
InvoiceLine.objects.create(
quantity=0,
unit_amount=0,
total_amount=0,
pool=old_pool,
from_injected_line=injected_line6,
)
injected_line7 = InjectedLine.objects.create(
event_date=datetime.date(2022, 9, 16),
slug='event-2022-09-15',
label='Event 2022-09-15',
quantity=3,
unit_amount=1.5,
total_amount=4.5,
user_external_id='user:1',
payer_external_id='payer:1',
regie=regie,
)
# nok, other campaign
DraftInvoiceLine.objects.create(
quantity=0,
unit_amount=0,
total_amount=0,
pool=other_pool,
from_injected_line=injected_line7,
)
# no agendas
assert (
@ -234,15 +360,13 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s
# no status
mock_status.return_value = []
assert (
utils.get_invoice_lines_for_user(
agendas=[agenda1, agenda2],
agendas_pricings=[agenda_pricing],
user_external_id='user:1',
pool=pool,
)
== []
lines = utils.get_invoice_lines_for_user(
agendas=[agenda1, agenda2],
agendas_pricings=[agenda_pricing],
user_external_id='user:1',
pool=pool,
)
assert len(lines) == 2 # injected lines
assert mock_status.call_args_list == [
mock.call(
agenda_slugs=['agenda-1', 'agenda-2'],
@ -365,8 +489,8 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s
adult_external_id='user:1',
),
]
assert len(lines) == 4
line1, line2, line3, line4 = lines
assert len(lines) == 6
line1, line2, line3, line4, line5, line6 = lines
assert isinstance(line1, DraftInvoiceLine)
assert line1.invoice is None
assert line1.slug == 'agenda-1@event-1'
@ -385,6 +509,7 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s
assert line1.pricing_data == {'foo1': 'bar1', 'pricing': 1}
assert line1.status == 'success'
assert line1.pool == pool
assert line1.from_injected_line is None
assert isinstance(line2, DraftInvoiceLine)
assert line2.invoice is None
assert line2.slug == 'agenda-1@event-2'
@ -403,6 +528,7 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s
assert line2.pricing_data == {'foo2': 'bar2', 'pricing': 2}
assert line2.status == 'success'
assert line2.pool == pool
assert line2.from_injected_line is None
assert isinstance(line3, DraftInvoiceLine)
assert line3.invoice is None
assert line3.slug == 'agenda-2@eveeent-1'
@ -421,6 +547,7 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s
assert line3.pricing_data == {'foo3': 'bar3', 'pricing': 3}
assert line3.status == 'success'
assert line3.pool == pool
assert line3.from_injected_line is None
assert isinstance(line4, DraftInvoiceLine)
assert line4.invoice is None
assert line4.slug == 'agenda-2@eveeent-2'
@ -439,6 +566,35 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s
assert line4.pricing_data == {'foo4': 'bar4', 'pricing': 4}
assert line4.status == 'success'
assert line4.pool == pool
assert line4.from_injected_line is None
assert isinstance(line5, DraftInvoiceLine)
assert line5.invoice is None
assert line5.slug == 'event-2022-09-01'
assert line5.label == 'Event 2022-09-01'
assert line5.quantity == 2
assert line5.unit_amount == 1.5
assert line5.total_amount == 3
assert line5.user_external_id == 'user:1'
assert line5.payer_external_id == 'user:1'
assert line5.event == {}
assert line5.pricing_data == {}
assert line5.status == 'success'
assert line5.pool == pool
assert line5.from_injected_line == injected_line2
assert isinstance(line6, DraftInvoiceLine)
assert line6.invoice is None
assert line6.slug == 'event-2022-09-30'
assert line6.label == 'Event 2022-09-30'
assert line6.quantity == 3
assert line6.unit_amount == 1.5
assert line6.total_amount == 4.5
assert line6.user_external_id == 'user:1'
assert line6.payer_external_id == 'user:1'
assert line6.event == {}
assert line6.pricing_data == {}
assert line6.status == 'success'
assert line6.pool == pool
assert line6.from_injected_line == injected_line4
@mock.patch('lingo.invoicing.utils.get_check_status')
@ -866,7 +1022,7 @@ def test_get_all_invoice_lines_queryset(mock_status):
pool=pool,
)
assert lines
assert len(ctx.captured_queries) == 7
assert len(ctx.captured_queries) == 9
def test_generate_invoices_from_lines():
@ -967,6 +1123,27 @@ def test_generate_invoices_from_lines():
status='success',
pool=pool,
)
injected_line = InjectedLine.objects.create(
event_date=datetime.date(2022, 9, 1),
slug='event-2022-09-01',
label='Event 2022-09-01',
quantity=1,
unit_amount=7,
total_amount=7,
user_external_id='user:1',
payer_external_id='user:1',
regie=regie1,
)
line7 = DraftInvoiceLine.objects.create(
quantity=1,
unit_amount=7,
total_amount=7,
user_external_id='user:1',
payer_external_id='user:1',
status='success',
pool=pool,
from_injected_line=injected_line,
)
invoices = utils.generate_invoices_from_lines(
agendas=[],
@ -987,7 +1164,7 @@ def test_generate_invoices_from_lines():
line6.delete()
invoices = utils.generate_invoices_from_lines(
agendas=[agenda1, agenda2, agenda3, agenda4],
all_lines=[line_error, line1, line2, line3, line4, line5],
all_lines=[line_error, line1, line2, line3, line4, line5, line7],
pool=pool,
)
assert len(invoices) == 3
@ -998,12 +1175,12 @@ def test_generate_invoices_from_lines():
invoice3.refresh_from_db()
assert isinstance(invoice1, DraftInvoice)
assert invoice1.label == 'Invoice from 2022-09-01 to 2022-09-30'
assert invoice1.total_amount == 8
assert invoice1.total_amount == 15
assert invoice1.date_issue == datetime.date(2022, 10, 31)
assert invoice1.regie == regie1
assert invoice1.payer == 'user:1'
assert invoice1.pool == pool
assert list(invoice1.lines.order_by('pk')) == [line1, line2, line5]
assert list(invoice1.lines.order_by('pk')) == [line1, line2, line5, line7]
assert isinstance(invoice2, DraftInvoice)
assert invoice2.label == 'Invoice from 2022-09-01 to 2022-09-30'
assert invoice2.total_amount == 3

View File

@ -70,6 +70,7 @@ def test_invoice_total_amount(draft):
assert invoice2.total_amount == 0
# update total_amount
line.status = 'success'
line.total_amount = 12
line.save()
invoice.refresh_from_db()