diff --git a/lingo/api/serializers.py b/lingo/api/serializers.py index 7ff4cc0..7518f18 100644 --- a/lingo/api/serializers.py +++ b/lingo/api/serializers.py @@ -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', + ] diff --git a/lingo/api/urls.py b/lingo/api/urls.py index 63c8695..68150b0 100644 --- a/lingo/api/urls.py +++ b/lingo/api/urls.py @@ -34,4 +34,9 @@ urlpatterns = [ views.invoicing_regies, name='api-invoicing-regies', ), + path( + 'invoicing/injected-lines/', + views.injected_lines, + name='api-invoicing-injected-lines', + ), ] diff --git a/lingo/api/views.py b/lingo/api/views.py index 1e4848e..7921f45 100644 --- a/lingo/api/views.py +++ b/lingo/api/views.py @@ -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(): + 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() diff --git a/lingo/invoicing/migrations/0008_injected_line.py b/lingo/invoicing/migrations/0008_injected_line.py new file mode 100644 index 0000000..d12a2df --- /dev/null +++ b/lingo/invoicing/migrations/0008_injected_line.py @@ -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' + ), + ), + ] diff --git a/lingo/invoicing/models.py b/lingo/invoicing/models.py index 89b955e..04b131f 100644 --- a/lingo/invoicing/models.py +++ b/lingo/invoicing/models.py @@ -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) diff --git a/lingo/invoicing/templates/lingo/invoicing/manager_pool_detail.html b/lingo/invoicing/templates/lingo/invoicing/manager_pool_detail.html index d84e050..e7077be 100644 --- a/lingo/invoicing/templates/lingo/invoicing/manager_pool_detail.html +++ b/lingo/invoicing/templates/lingo/invoicing/manager_pool_detail.html @@ -33,7 +33,7 @@ {% trans "PK" %} {% trans "Invoice PK" %} {% trans "Label" %} - {% trans "Event" %} + {% trans "Slug" %} {% trans "Quantity" %} {% trans "Unit amount" %} {% trans "Total amount" %} @@ -49,7 +49,7 @@ {{ line.pk }} {{ line.invoice_id|default:'' }} {{ line.label }} - {{ line.event.slug }} + {{ line.slug }} {{ line.quantity }} {{ line.unit_amount }} {{ line.total_amount }} @@ -58,6 +58,7 @@ {{ line.get_status_display }} {% if line.status != 'success' %}({{ line.get_error_display }}){% endif %} + {% if line.from_injected_line_id %}({% trans "Injected" %}){% endif %} {% trans "see details" %} diff --git a/lingo/invoicing/utils.py b/lingo/invoicing/utils.py index fd843b0..f8ee024 100644 --- a/lingo/invoicing/utils.py +++ b/lingo/invoicing/utils.py @@ -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] diff --git a/tests/api/test_invoicing.py b/tests/api/test_invoicing.py index 5538a0b..6d5fef9 100644 --- a/tests/api/test_invoicing.py +++ b/tests/api/test_invoicing.py @@ -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 diff --git a/tests/invoicing/test_invoice_generation.py b/tests/invoicing/test_invoice_generation.py index 825c13d..4426b8f 100644 --- a/tests/invoicing/test_invoice_generation.py +++ b/tests/invoicing/test_invoice_generation.py @@ -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 diff --git a/tests/invoicing/test_models.py b/tests/invoicing/test_models.py index 4df6d4c..7790a31 100644 --- a/tests/invoicing/test_models.py +++ b/tests/invoicing/test_models.py @@ -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()