From ecb8c5adf30f93d5b03fb2adef2f0b7933a806ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= Date: Tue, 22 Nov 2022 08:51:11 +0100 Subject: [PATCH 1/4] invoicing: Invoice models (#71528) --- lingo/invoicing/migrations/0002_invoice.py | 124 +++++++++++++++++++++ lingo/invoicing/models.py | 53 +++++++++ 2 files changed, 177 insertions(+) create mode 100644 lingo/invoicing/migrations/0002_invoice.py diff --git a/lingo/invoicing/migrations/0002_invoice.py b/lingo/invoicing/migrations/0002_invoice.py new file mode 100644 index 0000000..6e67038 --- /dev/null +++ b/lingo/invoicing/migrations/0002_invoice.py @@ -0,0 +1,124 @@ +import django.contrib.postgres.fields.jsonb +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoicing', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Invoice', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('label', models.CharField(max_length=300, verbose_name='Label')), + ('total_amount', models.DecimalField(decimal_places=2, max_digits=9, default=0)), + ('date_issue', models.DateField(verbose_name='Issue date')), + ('payer', models.CharField(max_length=300, verbose_name='Payer')), + ( + 'regie', + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to='invoicing.Regie', + ), + ), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='InvoiceLine', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('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)), + ('event', django.contrib.postgres.fields.jsonb.JSONField(default=dict)), + ('pricing_data', django.contrib.postgres.fields.jsonb.JSONField(default=dict)), + ( + 'status', + models.CharField(choices=[('success', 'Success'), ('error', 'Error')], max_length=10), + ), + ( + 'invoice', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to='invoicing.Invoice', + related_name='lines', + ), + ), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DraftInvoice', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('label', models.CharField(max_length=300, verbose_name='Label')), + ('total_amount', models.DecimalField(decimal_places=2, max_digits=9, default=0)), + ('date_issue', models.DateField(verbose_name='Issue date')), + ('payer', models.CharField(max_length=300, verbose_name='Payer')), + ( + 'regie', + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='invoicing.Regie'), + ), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DraftInvoiceLine', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('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)), + ('event', django.contrib.postgres.fields.jsonb.JSONField(default=dict)), + ('pricing_data', django.contrib.postgres.fields.jsonb.JSONField(default=dict)), + ( + 'status', + models.CharField(choices=[('success', 'Success'), ('error', 'Error')], max_length=10), + ), + ( + 'invoice', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to='invoicing.DraftInvoice', + related_name='lines', + ), + ), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/lingo/invoicing/models.py b/lingo/invoicing/models.py index 7692aa8..ea9428b 100644 --- a/lingo/invoicing/models.py +++ b/lingo/invoicing/models.py @@ -17,6 +17,7 @@ import copy from django.contrib.auth.models import Group +from django.contrib.postgres.fields import JSONField from django.db import models from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ @@ -84,3 +85,55 @@ class Regie(models.Model): regie, created = cls.objects.update_or_create(slug=data['slug'], defaults=data) return created, regie + + +class AbstractInvoice(models.Model): + label = models.CharField(_('Label'), max_length=300) + total_amount = models.DecimalField( + max_digits=9, decimal_places=2, default=0 + ) # XXX PG trigger to maintain this ? + date_issue = models.DateField(_('Issue date')) + regie = models.ForeignKey(Regie, on_delete=models.PROTECT) + payer = models.CharField(_('Payer'), max_length=300) + + class Meta: + abstract = True + + +class DraftInvoice(AbstractInvoice): + pass + + +class Invoice(AbstractInvoice): + pass + + +class AbstractInvoiceLine(models.Model): + 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) + event = JSONField(default=dict) + pricing_data = JSONField(default=dict) + status = models.CharField( + max_length=10, + choices=[ + ('success', _('Success')), + ('error', _('Error')), + ], + ) + + class Meta: + abstract = True + + +class DraftInvoiceLine(AbstractInvoiceLine): + invoice = models.ForeignKey(DraftInvoice, on_delete=models.PROTECT, null=True, related_name='lines') + + +class InvoiceLine(AbstractInvoiceLine): + invoice = models.ForeignKey(Invoice, on_delete=models.PROTECT, null=True, related_name='lines') -- 2.39.2 From 2b506e6269b9f970bc1e54c8a86eac6b9d87dca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= Date: Mon, 28 Nov 2022 16:39:04 +0100 Subject: [PATCH 2/4] agendas: function to get check_status from chrono (#71528) and new params for get_subscriptions function --- lingo/agendas/chrono.py | 39 ++++++++-- tests/agendas/test_chrono.py | 147 ++++++++++++++++++++++++++++++++--- 2 files changed, 169 insertions(+), 17 deletions(-) diff --git a/lingo/agendas/chrono.py b/lingo/agendas/chrono.py index 9fc5f8d..d7cbc72 100644 --- a/lingo/agendas/chrono.py +++ b/lingo/agendas/chrono.py @@ -132,14 +132,41 @@ def get_events(event_slugs, error_message=None, error_message_with_details=None) return result['data'] -def get_subscriptions(agenda_slug, user_external_id): - result = get_chrono_json( - 'api/agenda/%s/subscription/?user_external_id=%s' % (agenda_slug, user_external_id) - ) - if not result or not result.get('data'): +def get_subscriptions(agenda_slug, user_external_id=None, date_start=None, date_end=None): + url = 'api/agenda/%s/subscription/' % agenda_slug + params = {} + if user_external_id: + params['user_external_id'] = user_external_id + if date_start: + params['date_start'] = date_start + if date_end: + params['date_end'] = date_end + if params: + url += '?%s' % '&'.join(['%s=%s' % (k, v) for k, v in params.items()]) + result = get_chrono_json(url) + if not result: raise ChronoError(_('Unable to get subscription details')) if result.get('err'): raise ChronoError(_('Unable to get subscription details (%s)') % result['err_desc']) - if not result.get('data'): + if 'data' not in result: raise ChronoError(_('Unable to get subscription details')) return result['data'] + + +def get_check_status(agenda_slugs, user_external_id, date_start, date_end): + result = get_chrono_json( + 'api/agendas/events/check-status/?user_external_id=%s&agendas=%s&date_start=%s&date_end=%s' + % ( + user_external_id, + ','.join(agenda_slugs), + date_start, + date_end, + ) + ) + if not result: + raise ChronoError(_('Unable to get check status')) + if result.get('err'): + raise ChronoError(_('Unable to get check status (%s)') % result['err_desc']) + if 'data' not in result: + raise ChronoError(_('Unable to get check status')) + return result['data'] diff --git a/tests/agendas/test_chrono.py b/tests/agendas/test_chrono.py index 738438d..508026e 100644 --- a/tests/agendas/test_chrono.py +++ b/tests/agendas/test_chrono.py @@ -1,3 +1,4 @@ +import datetime import json from unittest import mock @@ -8,6 +9,7 @@ from requests.models import Response from lingo.agendas.chrono import ( ChronoError, collect_agenda_data, + get_check_status, get_event, get_events, get_subscriptions, @@ -282,12 +284,12 @@ def test_get_events(): def test_get_subscriptions_no_service(settings): settings.KNOWN_SERVICES = {} with pytest.raises(ChronoError) as e: - get_subscriptions('foo', 'user:1') + get_subscriptions(agenda_slug='foo') assert str(e.value) == 'Unable to get subscription details' settings.KNOWN_SERVICES = {'other': []} with pytest.raises(ChronoError) as e: - get_subscriptions('foo', 'user:1') + get_subscriptions(agenda_slug='foo') assert str(e.value) == 'Unable to get subscription details' @@ -295,7 +297,7 @@ def test_get_subscriptions(): with mock.patch('requests.Session.get') as requests_get: requests_get.side_effect = ConnectionError() with pytest.raises(ChronoError) as e: - get_subscriptions('foo', 'user:1') + get_subscriptions(agenda_slug='foo') assert str(e.value) == 'Unable to get subscription details' with mock.patch('requests.Session.get') as requests_get: @@ -303,7 +305,7 @@ def test_get_subscriptions(): mock_resp.status_code = 500 requests_get.return_value = mock_resp with pytest.raises(ChronoError) as e: - get_subscriptions('foo', 'user:1') + get_subscriptions(agenda_slug='foo') assert str(e.value) == 'Unable to get subscription details' with mock.patch('requests.Session.get') as requests_get: @@ -311,25 +313,148 @@ def test_get_subscriptions(): mock_resp.status_code = 404 requests_get.return_value = mock_resp with pytest.raises(ChronoError) as e: - get_subscriptions('foo', 'user:1') + get_subscriptions(agenda_slug='foo') assert str(e.value) == 'Unable to get subscription details' with mock.patch('requests.Session.get') as requests_get: requests_get.return_value = MockedRequestResponse(content=json.dumps({'foo': 'bar'})) with pytest.raises(ChronoError) as e: - get_subscriptions('foo', 'user:1') + get_subscriptions(agenda_slug='foo') assert str(e.value) == 'Unable to get subscription details' data = {'data': []} with mock.patch('requests.Session.get') as requests_get: requests_get.return_value = MockedRequestResponse(content=json.dumps(data)) - with pytest.raises(ChronoError) as e: - get_subscriptions('foo', 'user:1') - assert str(e.value) == 'Unable to get subscription details' - assert requests_get.call_args_list[0][0] == ('api/agenda/foo/subscription/?user_external_id=user:1',) + assert get_subscriptions(agenda_slug='foo') == [] + assert requests_get.call_args_list[0][0] == ('api/agenda/foo/subscription/',) assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org' data = {'data': ['foo', 'bar']} with mock.patch('requests.Session.get') as requests_get: requests_get.return_value = MockedRequestResponse(content=json.dumps(data)) - assert get_subscriptions('foo', 'user:1') == ['foo', 'bar'] + assert get_subscriptions(agenda_slug='foo') == ['foo', 'bar'] + + data = {'data': []} + with mock.patch('requests.Session.get') as requests_get: + requests_get.return_value = MockedRequestResponse(content=json.dumps(data)) + get_subscriptions(agenda_slug='foo', user_external_id='user:1') + assert requests_get.call_args_list[0][0] == ('api/agenda/foo/subscription/?user_external_id=user:1',) + assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org' + requests_get.reset_mock() + get_subscriptions(agenda_slug='foo', date_start=datetime.date(2022, 9, 1)) + assert requests_get.call_args_list[0][0] == ('api/agenda/foo/subscription/?date_start=2022-09-01',) + assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org' + requests_get.reset_mock() + get_subscriptions(agenda_slug='foo', date_end=datetime.date(2022, 10, 1)) + assert requests_get.call_args_list[0][0] == ('api/agenda/foo/subscription/?date_end=2022-10-01',) + assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org' + requests_get.reset_mock() + get_subscriptions( + agenda_slug='foo', + user_external_id='user:1', + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert requests_get.call_args_list[0][0] == ( + 'api/agenda/foo/subscription/?user_external_id=user:1&date_start=2022-09-01&date_end=2022-10-01', + ) + assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org' + + +def test_get_check_status_no_service(settings): + settings.KNOWN_SERVICES = {} + with pytest.raises(ChronoError) as e: + get_check_status( + agenda_slugs=['foo'], + user_external_id='user:1', + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert str(e.value) == 'Unable to get check status' + + settings.KNOWN_SERVICES = {'other': []} + with pytest.raises(ChronoError) as e: + get_check_status( + agenda_slugs=['foo'], + user_external_id='user:1', + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert str(e.value) == 'Unable to get check status' + + +def test_get_check_status(): + with mock.patch('requests.Session.get') as requests_get: + requests_get.side_effect = ConnectionError() + with pytest.raises(ChronoError) as e: + get_check_status( + agenda_slugs=['foo', 'bar'], + user_external_id='user:1', + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert str(e.value) == 'Unable to get check status' + + with mock.patch('requests.Session.get') as requests_get: + mock_resp = Response() + mock_resp.status_code = 500 + requests_get.return_value = mock_resp + with pytest.raises(ChronoError) as e: + get_check_status( + agenda_slugs=['foo', 'bar'], + user_external_id='user:1', + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert str(e.value) == 'Unable to get check status' + + with mock.patch('requests.Session.get') as requests_get: + mock_resp = Response() + mock_resp.status_code = 404 + requests_get.return_value = mock_resp + with pytest.raises(ChronoError) as e: + get_check_status( + agenda_slugs=['foo', 'bar'], + user_external_id='user:1', + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert str(e.value) == 'Unable to get check status' + + with mock.patch('requests.Session.get') as requests_get: + requests_get.return_value = MockedRequestResponse(content=json.dumps({'foo': 'bar'})) + with pytest.raises(ChronoError) as e: + get_check_status( + agenda_slugs=['foo', 'bar'], + user_external_id='user:1', + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert str(e.value) == 'Unable to get check status' + + data = {'data': []} + with mock.patch('requests.Session.get') as requests_get: + requests_get.return_value = MockedRequestResponse(content=json.dumps(data)) + assert ( + get_check_status( + agenda_slugs=['foo', 'bar'], + user_external_id='user:1', + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + == [] + ) + assert requests_get.call_args_list[0][0] == ( + 'api/agendas/events/check-status/?user_external_id=user:1&agendas=foo,bar&date_start=2022-09-01&date_end=2022-10-01', + ) + assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org' + + data = {'data': ['foo', 'bar']} + with mock.patch('requests.Session.get') as requests_get: + requests_get.return_value = MockedRequestResponse(content=json.dumps(data)) + assert get_check_status( + agenda_slugs=['foo', 'bar'], + user_external_id='user:1', + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) == ['foo', 'bar'] -- 2.39.2 From b9951700489df44ba63a3fea7c147f44b49d8a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= Date: Mon, 28 Nov 2022 16:41:29 +0100 Subject: [PATCH 3/4] invoicing: draft invoice lines generation (#71528) --- MANIFEST.in | 3 + lingo/invoicing/management/__init__.py | 0 .../invoicing/management/commands/__init__.py | 0 .../management/commands/generate_invoices.py | 45 + lingo/invoicing/migrations/0003_invoice.py | 27 + lingo/invoicing/models.py | 4 +- .../sql/invoice_triggers_for_amount.sql | 44 + lingo/invoicing/utils.py | 221 +++ tests/invoicing/test_invoice_generation.py | 1195 +++++++++++++++++ tests/invoicing/test_models.py | 136 ++ 10 files changed, 1672 insertions(+), 3 deletions(-) create mode 100644 lingo/invoicing/management/__init__.py create mode 100644 lingo/invoicing/management/commands/__init__.py create mode 100644 lingo/invoicing/management/commands/generate_invoices.py create mode 100644 lingo/invoicing/migrations/0003_invoice.py create mode 100644 lingo/invoicing/sql/invoice_triggers_for_amount.sql create mode 100644 lingo/invoicing/utils.py create mode 100644 tests/invoicing/test_invoice_generation.py create mode 100644 tests/invoicing/test_models.py diff --git a/MANIFEST.in b/MANIFEST.in index a33d6c9..ba0b137 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -10,6 +10,9 @@ recursive-include lingo/templates *.html *.txt recursive-include lingo/manager/templates *.html *.txt recursive-include lingo/pricing/templates *.html *.txt +# sql (migrations) +recursive-include lingo/invoicing/sql *.sql + include COPYING README include MANIFEST.in include VERSION diff --git a/lingo/invoicing/management/__init__.py b/lingo/invoicing/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lingo/invoicing/management/commands/__init__.py b/lingo/invoicing/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lingo/invoicing/management/commands/generate_invoices.py b/lingo/invoicing/management/commands/generate_invoices.py new file mode 100644 index 0000000..8d80965 --- /dev/null +++ b/lingo/invoicing/management/commands/generate_invoices.py @@ -0,0 +1,45 @@ +# lingo - payment and billing system +# Copyright (C) 2022 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 . + +import datetime + +from django.core.management.base import BaseCommand, CommandError + +from lingo.invoicing.utils import generate_invoices + + +class Command(BaseCommand): + help = 'Generate invoicing for a period' + + def add_arguments(self, parser): + parser.add_argument('date_start') + parser.add_argument('date_end') + + def handle(self, *args, **options): + try: + date_start = datetime.datetime.fromisoformat(options['date_start']).date() + except ValueError: + raise CommandError('Bad value "%s" for date_start' % options['date_start']) + try: + date_end = datetime.datetime.fromisoformat(options['date_end']).date() + except ValueError: + raise CommandError('Bad value "%s" for date_end' % options['date_end']) + + generate_invoices(date_start=date_start, date_end=date_end) + + self.stdout.write( + self.style.SUCCESS('Invoicing generation OK (start: %s, end: %s)' % (date_start, date_end)) + ) diff --git a/lingo/invoicing/migrations/0003_invoice.py b/lingo/invoicing/migrations/0003_invoice.py new file mode 100644 index 0000000..c992e28 --- /dev/null +++ b/lingo/invoicing/migrations/0003_invoice.py @@ -0,0 +1,27 @@ +import os + +from django.db import migrations + +with open( + os.path.join( + os.path.dirname(os.path.realpath(__file__)), + '..', + 'sql', + 'invoice_triggers_for_amount.sql', + ) +) as sql_file: + sql_triggers = sql_file.read() + + +sql_forwards = sql_triggers + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoicing', '0002_invoice'), + ] + + operations = [ + migrations.RunSQL(sql=sql_forwards, reverse_sql=migrations.RunSQL.noop), + ] diff --git a/lingo/invoicing/models.py b/lingo/invoicing/models.py index ea9428b..4471994 100644 --- a/lingo/invoicing/models.py +++ b/lingo/invoicing/models.py @@ -89,9 +89,7 @@ class Regie(models.Model): class AbstractInvoice(models.Model): label = models.CharField(_('Label'), max_length=300) - total_amount = models.DecimalField( - max_digits=9, decimal_places=2, default=0 - ) # XXX PG trigger to maintain this ? + total_amount = models.DecimalField(max_digits=9, decimal_places=2, default=0) date_issue = models.DateField(_('Issue date')) regie = models.ForeignKey(Regie, on_delete=models.PROTECT) payer = models.CharField(_('Payer'), max_length=300) diff --git a/lingo/invoicing/sql/invoice_triggers_for_amount.sql b/lingo/invoicing/sql/invoice_triggers_for_amount.sql new file mode 100644 index 0000000..65b874c --- /dev/null +++ b/lingo/invoicing/sql/invoice_triggers_for_amount.sql @@ -0,0 +1,44 @@ +CREATE OR REPLACE FUNCTION set_invoice_total_amount() RETURNS TRIGGER AS $$ + DECLARE + invoice_ids integer[]; + BEGIN + IF TG_OP = 'INSERT' THEN + invoice_ids := ARRAY[NEW.invoice_id]; + ELSIF TG_OP = 'DELETE' THEN + invoice_ids := ARRAY[OLD.invoice_id]; + ELSIF TG_OP = 'UPDATE' THEN + invoice_ids := ARRAY[NEW.invoice_id, OLD.invoice_id]; + END IF; + + EXECUTE 'UPDATE ' || substring(TG_TABLE_NAME for length(TG_TABLE_NAME) - 4) || ' i + SET total_amount = COALESCE( + ( + SELECT SUM(l.total_amount) + FROM ' || TG_TABLE_NAME || ' l + WHERE l.invoice_id = i.id + AND l.status = ''success'' + ), 0 + ) + WHERE id = ANY($1);' USING invoice_ids; + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + ELSE + RETURN NEW; + END IF; + END; +$$ LANGUAGE plpgsql; + + +DROP TRIGGER IF EXISTS set_draftinvoice_total_amount_trg ON invoicing_draftinvoiceline; +CREATE TRIGGER set_draftinvoice_total_amount_trg + AFTER INSERT OR UPDATE OF total_amount, invoice_id, status OR DELETE ON invoicing_draftinvoiceline + FOR EACH ROW + EXECUTE PROCEDURE set_invoice_total_amount(); + + +DROP TRIGGER IF EXISTS set_invoice_total_amount_trg ON invoicing_invoiceline; +CREATE TRIGGER set_invoice_total_amount_trg + AFTER INSERT OR UPDATE OF total_amount, invoice_id, status OR DELETE ON invoicing_invoiceline + FOR EACH ROW + EXECUTE PROCEDURE set_invoice_total_amount(); diff --git a/lingo/invoicing/utils.py b/lingo/invoicing/utils.py new file mode 100644 index 0000000..9ac8657 --- /dev/null +++ b/lingo/invoicing/utils.py @@ -0,0 +1,221 @@ +# lingo - payment and billing system +# Copyright (C) 2022 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 . + +import datetime + +from django.test.client import RequestFactory +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 +from lingo.pricing.models import AgendaPricing, AgendaPricingNotFound, PricingError + + +def get_agendas(date_start, date_end): + agendas_pricings = AgendaPricing.objects.filter(flat_fee_schedule=False).extra( + where=["(date_start, date_end) OVERLAPS (%s, %s)"], params=[date_start, date_end] + ) + return Agenda.objects.filter(pk__in=agendas_pricings.values('agendas')).order_by('pk') + + +def get_all_subscriptions(agendas, date_start, date_end): + # result: + # { + # 'user_id': { + # 'agenda_slug': [sub1, sub2, ...], + # 'agenda_slug2': [sub1, sub2, ...], + # }, + # ... + # } + all_subscriptions = {} + for agenda in agendas: + subscriptions = get_subscriptions(agenda_slug=agenda.slug, date_start=date_start, date_end=date_end) + for subscription in subscriptions: + if subscription['user_external_id'] not in all_subscriptions: + all_subscriptions[subscription['user_external_id']] = {} + user_subs = all_subscriptions[subscription['user_external_id']] + if agenda.slug not in user_subs: + user_subs[agenda.slug] = [] + user_subs[agenda.slug].append(subscription) + return all_subscriptions + + +def get_invoice_lines_for_user(agendas, user_external_id, subscriptions, date_start, date_end): + def get_subscription(subscriptions, event_date): + # get subscription matching event_date + for subscription in subscriptions: + sub_start_date = datetime.date.fromisoformat(subscription['date_start']) + sub_end_date = datetime.date.fromisoformat(subscription['date_end']) + if sub_start_date > event_date: + continue + if sub_end_date <= event_date: + continue + return subscription + + if not agendas or not subscriptions: + return [] + + # get check status for user_external_id, on agendas, for the period + check_status_list = get_check_status( + agenda_slugs=list(subscriptions.keys()), + user_external_id=user_external_id, + date_start=date_start, + date_end=date_end, + ) + request = RequestFactory().get('/') # XXX + agendas_by_slug = {a.slug: a for a in agendas} + + # build lines from check status + lines = [] + for check_status in check_status_list: + serialized_event = check_status['event'] + event_date = datetime.datetime.fromisoformat(serialized_event['start_datetime']).date() + event_slug = '%s@%s' % (serialized_event['agenda'], serialized_event['slug']) + if serialized_event['agenda'] not in subscriptions: + # should not happen, check-status endpoint is based on real subscriptions + continue + serialized_subscription = get_subscription( + subscriptions=subscriptions[serialized_event['agenda']], + event_date=event_date, + ) + if not serialized_subscription: + # should not happen, check-status endpoint is based on real subscriptions + continue + + agenda = agendas_by_slug[serialized_event['agenda']] + try: + agenda_pricing = AgendaPricing.get_agenda_pricing( + agenda=agenda, start_date=event_date, flat_fee_schedule=False + ) # XXX optimize SQL request + pricing_data = agenda_pricing.get_pricing_data_for_event( + request=request, + agenda=agenda, + event=serialized_event, + subscription=serialized_subscription, + check_status=check_status['check_status'], + booking=check_status['booking'], + user_external_id=user_external_id, + adult_external_id=user_external_id, # XXX + ) + except AgendaPricingNotFound: + # no pricing, no invoice + # can happen if pricing model defined only on a part of the requested period + # XXX error, warning, or ignore ? + continue + except PricingError as e: + # XXX explicit each error + # XXX and log context ? + pricing_error = {'error': e.details} + lines.append( + DraftInvoiceLine( + slug=event_slug, + label=serialized_event['label'], + quantity=0, + unit_amount=0, + total_amount=0, + user_external_id=user_external_id, + payer_external_id=user_external_id, # XXX + event=serialized_event, + pricing_data=pricing_error, + status='error', + ) + ) + else: + # XXX log all context ! + lines.append( + DraftInvoiceLine( + slug=event_slug, + label=serialized_event['label'], + quantity=1, + unit_amount=pricing_data['pricing'], + total_amount=pricing_data['pricing'], + user_external_id=user_external_id, + payer_external_id=user_external_id, # XXX + event=serialized_event, + pricing_data=pricing_data, + status='success', + ) + ) + DraftInvoiceLine.objects.bulk_create(lines) + return lines + + +def get_all_invoice_lines(agendas, subscriptions, date_start, date_end): + lines = [] + for user_external_id, user_subs in subscriptions.items(): + # generate lines for each user + lines += get_invoice_lines_for_user( + agendas=agendas, + user_external_id=user_external_id, + subscriptions=user_subs, + date_start=date_start, + date_end=date_end, + ) + return lines + + +def generate_invoices_from_lines(agendas, all_lines, date_start, date_end): + agendas_by_slug = {a.slug: a for a in agendas} + + # regroup lines by regie, and by payer_external_id (payer) + lines_by_regie = {} + for line in all_lines: + 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: + # XXX what should we do if regie is not configured ? + continue + if regie.pk not in lines_by_regie: + lines_by_regie[regie.pk] = {} + regie_subs = lines_by_regie[regie.pk] + if line.payer_external_id not in regie_subs: + regie_subs[line.payer_external_id] = [] + regie_subs[line.payer_external_id].append(line) + + # generate invoices by regie and by payer_external_id (payer) + invoices = [] + for regie_id, regie_subs in lines_by_regie.items(): + for payer_external_id, adult_lines in regie_subs.items(): + invoice = DraftInvoice.objects.create( + label=_('Invoice from %s to %s') % (date_start, date_end - datetime.timedelta(days=1)), + date_issue=date_end, # XXX + regie_id=regie_id, + payer=payer_external_id, + ) + DraftInvoiceLine.objects.filter(pk__in=[line.pk for line in adult_lines]).update(invoice=invoice) + invoices.append(invoice) + + return invoices + + +def generate_invoices(date_start, date_end): + # get agendas with pricing corresponding to the period + agendas = get_agendas(date_start=date_start, date_end=date_end) + # get subscriptions for each agenda, for the period + subscriptions = get_all_subscriptions(agendas=agendas, date_start=date_start, date_end=date_end) + # get invoice lines for all subscribed users, for each agenda in the corresponding period + lines = get_all_invoice_lines( + agendas=agendas, subscriptions=subscriptions, date_start=date_start, date_end=date_end + ) + # and generate invoices + generate_invoices_from_lines(agendas=agendas, all_lines=lines, date_start=date_start, date_end=date_end) diff --git a/tests/invoicing/test_invoice_generation.py b/tests/invoicing/test_invoice_generation.py new file mode 100644 index 0000000..b3e6765 --- /dev/null +++ b/tests/invoicing/test_invoice_generation.py @@ -0,0 +1,1195 @@ +import datetime +from unittest import mock + +import pytest +from django.core.management import CommandError, call_command + +from lingo.agendas.chrono import ChronoError +from lingo.agendas.models import Agenda +from lingo.invoicing import utils +from lingo.invoicing.models import DraftInvoice, DraftInvoiceLine, Regie +from lingo.pricing.models import AgendaPricing, Pricing, PricingError + +pytestmark = pytest.mark.django_db + + +def test_get_agendas(): + agenda1 = Agenda.objects.create(label='Agenda 1') + agenda2 = Agenda.objects.create(label='Agenda 2') + agenda3 = Agenda.objects.create(label='Agenda 3') + Agenda.objects.create(label='Agenda 4') + pricing = Pricing.objects.create(label='Foo bar 1') + + # no agenda pricing defined + assert ( + list(utils.get_agendas(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1))) + == [] + ) + + # agenda pricing, but for flat_fee_schedule + agenda_pricing = AgendaPricing.objects.create( + pricing=pricing, + date_start=datetime.date(year=2022, month=9, day=1), + date_end=datetime.date(year=2022, month=10, day=1), + flat_fee_schedule=True, # wrong config + ) + agenda_pricing.agendas.add(agenda1) + assert ( + list(utils.get_agendas(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1))) + == [] + ) + + # create some agenda pricing + agenda_pricing1 = AgendaPricing.objects.create( + pricing=pricing, + date_start=datetime.date(year=2021, month=9, day=1), + date_end=datetime.date(year=2021, month=10, day=1), + ) + agenda_pricing2 = AgendaPricing.objects.create( + pricing=pricing, + date_start=datetime.date(year=2022, month=9, day=1), + date_end=datetime.date(year=2022, month=10, day=1), + ) + assert ( + list(utils.get_agendas(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1))) + == [] + ) + + # link agendas to agenda pricing + agenda_pricing1.agendas.add(agenda1, agenda2) + agenda_pricing2.agendas.add(agenda3) + + assert list( + utils.get_agendas(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)) + ) == [agenda3] + assert list( + utils.get_agendas(date_start=datetime.date(2021, 9, 1), date_end=datetime.date(2021, 10, 1)) + ) == [agenda1, agenda2] + assert ( + list(utils.get_agendas(date_start=datetime.date(2022, 8, 31), date_end=datetime.date(2022, 9, 1))) + == [] + ) + assert list( + utils.get_agendas(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 9, 2)) + ) == [agenda3] + assert list( + utils.get_agendas(date_start=datetime.date(2022, 9, 30), date_end=datetime.date(2022, 10, 1)) + ) == [agenda3] + assert ( + list(utils.get_agendas(date_start=datetime.date(2022, 10, 1), date_end=datetime.date(2022, 10, 2))) + == [] + ) + assert list( + utils.get_agendas(date_start=datetime.date(2021, 9, 15), date_end=datetime.date(2022, 9, 15)) + ) == [agenda1, agenda2, agenda3] + + +@mock.patch('lingo.invoicing.utils.get_subscriptions') +def test_get_all_subscriptions_error(mock_subscriptions): + agenda = Agenda.objects.create(label='Agenda') + mock_subscriptions.side_effect = ChronoError('foo baz') + with pytest.raises(ChronoError): + utils.get_all_subscriptions( + agendas=[agenda], date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1) + ) + + +@mock.patch('lingo.invoicing.utils.get_subscriptions') +def test_get_all_subscriptions(mock_subscriptions): + agenda1 = Agenda.objects.create(label='Agenda 1') + agenda2 = Agenda.objects.create(label='Agenda 2') + + # no agendas + assert ( + utils.get_all_subscriptions( + agendas=[], date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1) + ) + == {} + ) + assert mock_subscriptions.call_args_list == [] + + # no subscriptions + mock_subscriptions.return_value = [] + assert ( + utils.get_all_subscriptions( + agendas=[agenda1, agenda2], + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + == {} + ) + assert mock_subscriptions.call_args_list == [ + mock.call( + agenda_slug='agenda-1', date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1) + ), + mock.call( + agenda_slug='agenda-2', date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1) + ), + ] + mock_subscriptions.reset_mock() + + # with subscriptions + mock_subscriptions.side_effect = [ + [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-08-01', + 'date_end': '2022-09-02', + }, + { + 'user_external_id': 'user:1', + 'date_start': '2022-09-02', + 'date_end': '2022-09-03', + }, + { + 'user_external_id': 'user:2', + 'date_start': '2022-09-02', + 'date_end': '2022-09-03', + }, + ], + [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-08-01', + 'date_end': '2022-10-01', + }, + ], + ] + assert utils.get_all_subscriptions( + agendas=[agenda1, agenda2], date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1) + ) == { + 'user:1': { + 'agenda-1': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-08-01', + 'date_end': '2022-09-02', + }, + { + 'user_external_id': 'user:1', + 'date_start': '2022-09-02', + 'date_end': '2022-09-03', + }, + ], + 'agenda-2': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-08-01', + 'date_end': '2022-10-01', + }, + ], + }, + 'user:2': { + 'agenda-1': [ + { + 'user_external_id': 'user:2', + 'date_start': '2022-09-02', + 'date_end': '2022-09-03', + }, + ], + }, + } + assert mock_subscriptions.call_args_list == [ + mock.call( + agenda_slug='agenda-1', date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1) + ), + mock.call( + agenda_slug='agenda-2', date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1) + ), + ] + + +@mock.patch('lingo.invoicing.utils.get_check_status') +def test_get_invoice_lines_for_user_check_status_error(mock_status): + mock_status.side_effect = ChronoError('foo baz') + with pytest.raises(ChronoError): + utils.get_invoice_lines_for_user( + agendas=['foo'], + user_external_id='user:1', + subscriptions={'foo': 'bar'}, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + + +@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): + agenda1 = Agenda.objects.create(label='Agenda 1') + agenda2 = Agenda.objects.create(label='Agenda 2') + pricing = Pricing.objects.create(label='Foo bar 1') + agenda_pricing = AgendaPricing.objects.create( + pricing=pricing, + date_start=datetime.date(year=2022, month=9, day=1), + date_end=datetime.date(year=2022, month=10, day=1), + ) + agenda_pricing.agendas.add(agenda1, agenda2) + + # no agendas + assert ( + utils.get_invoice_lines_for_user( + agendas=[], + user_external_id='user:1', + subscriptions={ + 'agenda-1': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-09-01', + 'date_end': '2022-10-01', + }, + ], + }, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + == [] + ) + assert mock_status.call_args_list == [] + assert mock_pricing_data_event.call_args_list == [] + + # no subscriptions + assert ( + utils.get_invoice_lines_for_user( + agendas=[agenda1, agenda2], + user_external_id='user:1', + subscriptions={}, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + == [] + ) + assert mock_status.call_args_list == [] + assert mock_pricing_data_event.call_args_list == [] + + # no status + mock_status.return_value = [] + assert ( + utils.get_invoice_lines_for_user( + agendas=[agenda1, agenda2], + user_external_id='user:1', + subscriptions={ + 'agenda-1': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-09-01', + 'date_end': '2022-10-01', + }, + ], + }, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + == [] + ) + assert mock_status.call_args_list == [ + mock.call( + agenda_slugs=['agenda-1'], + user_external_id='user:1', + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + ] + mock_status.reset_mock() + assert mock_pricing_data_event.call_args_list == [] + + # agenda and subscriptions not matching + mock_status.return_value = [ + { + 'event': { + 'agenda': 'agenda-1', + 'start_datetime': '2022-09-01T12:00:00+02:00', + 'slug': 'event', + 'label': 'Event', + } + } + ] + assert ( + utils.get_invoice_lines_for_user( + agendas=[agenda1, agenda2], + user_external_id='user:1', + subscriptions={ + 'unknown': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-09-01', + 'date_end': '2022-10-01', + }, + ], + }, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + == [] + ) + assert mock_status.call_args_list == [ + mock.call( + agenda_slugs=['unknown'], + user_external_id='user:1', + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + ] + mock_status.reset_mock() + assert mock_pricing_data_event.call_args_list == [] + + # correct data + mock_pricing_data_event.side_effect = [ + {'foo1': 'bar1', 'pricing': 1}, + {'foo2': 'bar2', 'pricing': 2}, + {'foo3': 'bar3', 'pricing': 3}, + {'foo4': 'bar4', 'pricing': 4}, + ] + mock_status.return_value = [ + # many events for agenda-1 + { + 'event': { + 'agenda': 'agenda-1', + 'start_datetime': '2022-09-01T12:00:00+02:00', + 'slug': 'event-1', + 'label': 'Event 1', + }, + 'check_status': {'foo': 'bar1'}, + 'booking': {'foo': 'baz1'}, + }, + { + 'event': { + 'agenda': 'agenda-1', + 'start_datetime': '2022-09-02T12:00:00+02:00', + 'slug': 'event-2', + 'label': 'Event 2', + }, + 'check_status': {'foo': 'bar2'}, + 'booking': {'foo': 'baz2'}, + }, + # and for agenda-2 + { + 'event': { + 'agenda': 'agenda-2', + 'start_datetime': '2022-09-01T13:00:00+02:00', + 'slug': 'eveeent-1', + 'label': 'Eveeent 1', + }, + 'check_status': {'foo': 'barrr1'}, + 'booking': {'foo': 'bazzz1'}, + }, + { + 'event': { + 'agenda': 'agenda-2', + 'start_datetime': '2022-09-02T13:00:00+02:00', + 'slug': 'eveeent-2', + 'label': 'Eveeent 2', + }, + 'check_status': {'foo': 'barrr2'}, + 'booking': {'foo': 'bazzz2'}, + }, + ] + lines = utils.get_invoice_lines_for_user( + agendas=[agenda1, agenda2], + user_external_id='user:1', + subscriptions={ + # matching dates + 'agenda-1': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-09-01', + 'date_end': '2022-10-01', + }, + ], + 'agenda-2': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-09-01', + 'date_end': '2022-10-01', + }, + ], + }, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert mock_pricing_data_event.call_args_list == [ + mock.call( + request=mock.ANY, + agenda=agenda1, + event={ + 'agenda': 'agenda-1', + 'start_datetime': '2022-09-01T12:00:00+02:00', + 'slug': 'event-1', + 'label': 'Event 1', + }, + subscription={'user_external_id': 'user:1', 'date_start': '2022-09-01', 'date_end': '2022-10-01'}, + check_status={'foo': 'bar1'}, + booking={'foo': 'baz1'}, + user_external_id='user:1', + adult_external_id='user:1', + ), + mock.call( + request=mock.ANY, + agenda=agenda1, + event={ + 'agenda': 'agenda-1', + 'start_datetime': '2022-09-02T12:00:00+02:00', + 'slug': 'event-2', + 'label': 'Event 2', + }, + subscription={'user_external_id': 'user:1', 'date_start': '2022-09-01', 'date_end': '2022-10-01'}, + check_status={'foo': 'bar2'}, + booking={'foo': 'baz2'}, + user_external_id='user:1', + adult_external_id='user:1', + ), + mock.call( + request=mock.ANY, + agenda=agenda2, + event={ + 'agenda': 'agenda-2', + 'start_datetime': '2022-09-01T13:00:00+02:00', + 'slug': 'eveeent-1', + 'label': 'Eveeent 1', + }, + subscription={'user_external_id': 'user:1', 'date_start': '2022-09-01', 'date_end': '2022-10-01'}, + check_status={'foo': 'barrr1'}, + booking={'foo': 'bazzz1'}, + user_external_id='user:1', + adult_external_id='user:1', + ), + mock.call( + request=mock.ANY, + agenda=agenda2, + event={ + 'agenda': 'agenda-2', + 'start_datetime': '2022-09-02T13:00:00+02:00', + 'slug': 'eveeent-2', + 'label': 'Eveeent 2', + }, + subscription={'user_external_id': 'user:1', 'date_start': '2022-09-01', 'date_end': '2022-10-01'}, + check_status={'foo': 'barrr2'}, + booking={'foo': 'bazzz2'}, + user_external_id='user:1', + adult_external_id='user:1', + ), + ] + assert len(lines) == 4 + line1, line2, line3, line4 = lines + assert isinstance(line1, DraftInvoiceLine) + assert line1.invoice is None + assert line1.slug == 'agenda-1@event-1' + assert line1.label == 'Event 1' + assert line1.quantity == 1 + assert line1.unit_amount == 1 + assert line1.total_amount == 1 + assert line1.user_external_id == 'user:1' + assert line1.payer_external_id == 'user:1' + assert line1.event == { + 'agenda': 'agenda-1', + 'start_datetime': '2022-09-01T12:00:00+02:00', + 'slug': 'event-1', + 'label': 'Event 1', + } + assert line1.pricing_data == {'foo1': 'bar1', 'pricing': 1} + assert line1.status == 'success' + assert isinstance(line2, DraftInvoiceLine) + assert line2.invoice is None + assert line2.slug == 'agenda-1@event-2' + assert line2.label == 'Event 2' + assert line2.quantity == 1 + assert line2.unit_amount == 2 + assert line2.total_amount == 2 + assert line2.user_external_id == 'user:1' + assert line2.payer_external_id == 'user:1' + assert line2.event == { + 'agenda': 'agenda-1', + 'start_datetime': '2022-09-02T12:00:00+02:00', + 'slug': 'event-2', + 'label': 'Event 2', + } + assert line2.pricing_data == {'foo2': 'bar2', 'pricing': 2} + assert line2.status == 'success' + assert isinstance(line3, DraftInvoiceLine) + assert line3.invoice is None + assert line3.slug == 'agenda-2@eveeent-1' + assert line3.label == 'Eveeent 1' + assert line3.quantity == 1 + assert line3.unit_amount == 3 + assert line3.total_amount == 3 + assert line3.user_external_id == 'user:1' + assert line3.payer_external_id == 'user:1' + assert line3.event == { + 'agenda': 'agenda-2', + 'start_datetime': '2022-09-01T13:00:00+02:00', + 'slug': 'eveeent-1', + 'label': 'Eveeent 1', + } + assert line3.pricing_data == {'foo3': 'bar3', 'pricing': 3} + assert line3.status == 'success' + assert isinstance(line4, DraftInvoiceLine) + assert line4.invoice is None + assert line4.slug == 'agenda-2@eveeent-2' + assert line4.label == 'Eveeent 2' + assert line4.quantity == 1 + assert line4.unit_amount == 4 + assert line4.total_amount == 4 + assert line4.user_external_id == 'user:1' + assert line4.payer_external_id == 'user:1' + assert line4.event == { + 'agenda': 'agenda-2', + 'start_datetime': '2022-09-02T13:00:00+02:00', + 'slug': 'eveeent-2', + 'label': 'Eveeent 2', + } + assert line4.pricing_data == {'foo4': 'bar4', 'pricing': 4} + assert line4.status == 'success' + + +@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_subscriptions_dates(mock_pricing_data_event, mock_status): + agenda = Agenda.objects.create(label='Agenda') + pricing = Pricing.objects.create(label='Foo bar') + agenda_pricing = AgendaPricing.objects.create( + pricing=pricing, + date_start=datetime.date(year=2022, month=9, day=1), + date_end=datetime.date(year=2022, month=10, day=1), + ) + agenda_pricing.agendas.add(agenda) + + mock_pricing_data_event.return_value = {'foo': 'bar', 'pricing': 42} + mock_status.return_value = [ + { + 'event': { + 'agenda': 'agenda', + 'start_datetime': '2022-09-01T12:00:00+02:00', + 'slug': 'event', + 'label': 'Event', + }, + 'check_status': {'foo': 'bar'}, + 'booking': {'foo': 'baz'}, + }, + ] + + assert ( + utils.get_invoice_lines_for_user( + agendas=[agenda], + user_external_id='user:1', + subscriptions={ + 'agenda': [ + { + 'user_external_id': 'user:1', + # event is outside the period + 'date_start': '2022-08-31', + 'date_end': '2022-09-01', + }, + ], + }, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + == [] + ) + assert mock_pricing_data_event.call_args_list == [] + + assert ( + utils.get_invoice_lines_for_user( + agendas=[agenda], + user_external_id='user:1', + subscriptions={ + 'agenda': [ + { + 'user_external_id': 'user:1', + # event is outside the period + 'date_start': '2022-09-02', + 'date_end': '2022-09-03', + }, + ], + }, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + == [] + ) + assert mock_pricing_data_event.call_args_list == [] + + lines = utils.get_invoice_lines_for_user( + agendas=[agenda], + user_external_id='user:1', + subscriptions={ + 'agenda': [ + { + 'user_external_id': 'user:1', + # event is outside the period + 'date_start': '2022-08-31', + 'date_end': '2022-09-01', + }, + { + 'user_external_id': 'user:1', + # bingo ! + 'date_start': '2022-09-01', + 'date_end': '2022-09-02', + }, + { + 'user_external_id': 'user:1', + # event is outside the period + 'date_start': '2022-09-02', + 'date_end': '2022-09-03', + }, + ], + }, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert len(lines) == 1 + assert mock_pricing_data_event.call_args_list == [ + mock.call( + request=mock.ANY, + agenda=agenda, + event={ + 'agenda': 'agenda', + 'start_datetime': '2022-09-01T12:00:00+02:00', + 'slug': 'event', + 'label': 'Event', + }, + subscription={'user_external_id': 'user:1', 'date_start': '2022-09-01', 'date_end': '2022-09-02'}, + check_status={'foo': 'bar'}, + booking={'foo': 'baz'}, + user_external_id='user:1', + adult_external_id='user:1', + ) + ] + + +@mock.patch('lingo.invoicing.utils.get_check_status') +def test_get_invoice_lines_for_user_check_status_agenda_pricing_dates(mock_status): + agenda = Agenda.objects.create(label='Agenda') + pricing = Pricing.objects.create(label='Foo bar') + agenda_pricing1 = AgendaPricing.objects.create( + pricing=pricing, + date_start=datetime.date(year=2022, month=9, day=1), + date_end=datetime.date(year=2022, month=10, day=1), + ) + agenda_pricing1.agendas.add(agenda) + agenda_pricing2 = AgendaPricing.objects.create( + pricing=pricing, + date_start=datetime.date(year=2022, month=10, day=1), + date_end=datetime.date(year=2022, month=11, day=1), + flat_fee_schedule=True, + ) + agenda_pricing2.agendas.add(agenda) + agenda_pricing3 = AgendaPricing.objects.create( + pricing=pricing, + date_start=datetime.date(year=2022, month=10, day=1), + date_end=datetime.date(year=2022, month=11, day=1), + ) + agenda_pricing3.agendas.add(agenda) + + pricing_data_event_patch = mock.patch.object(AgendaPricing, 'get_pricing_data_for_event', autospec=True) + + # check agenda pricing of september is used + for event_date in ['2022-09-01T12:00:00+02:00', '2022-09-30T12:00:00+02:00']: + mock_status.return_value = [ + { + 'event': { + 'agenda': 'agenda', + 'start_datetime': event_date, + 'slug': 'event', + 'label': 'Event', + }, + 'check_status': {'foo': 'bar'}, + 'booking': {'foo': 'baz'}, + }, + ] + with pricing_data_event_patch as mock_pricing_data_event: + mock_pricing_data_event.return_value = {'foo': 'bar', 'pricing': 42} + lines = utils.get_invoice_lines_for_user( + agendas=[agenda], + user_external_id='user:1', + subscriptions={ + 'agenda': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-01-01', + 'date_end': '2023-01-01', + }, + ], + }, + date_start=datetime.date(2022, 1, 1), + date_end=datetime.date(2023, 1, 1), + ) + assert len(lines) == 1 + assert mock_pricing_data_event.call_args_list[0][0][0] == agenda_pricing1 + + # check agenda pricing of october is used + for event_date in ['2022-10-01T12:00:00+02:00', '2022-10-31T12:00:00+02:00']: + mock_status.return_value = [ + { + 'event': { + 'agenda': 'agenda', + 'start_datetime': event_date, + 'slug': 'event', + 'label': 'Event', + }, + 'check_status': {'foo': 'bar'}, + 'booking': {'foo': 'baz'}, + }, + ] + with pricing_data_event_patch as mock_pricing_data_event: + mock_pricing_data_event.return_value = {'foo': 'bar', 'pricing': 42} + lines = utils.get_invoice_lines_for_user( + agendas=[agenda], + user_external_id='user:1', + subscriptions={ + 'agenda': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-01-01', + 'date_end': '2023-01-01', + }, + ], + }, + date_start=datetime.date(2022, 1, 1), + date_end=datetime.date(2023, 1, 1), + ) + assert len(lines) == 1 + assert mock_pricing_data_event.call_args_list[0][0][0] == agenda_pricing3 + + # no matching agenda pricing + for event_date in ['2022-08-31T12:00:00+02:00', '2022-11-01T12:00:00+02:00']: + mock_status.return_value = [ + { + 'event': { + 'agenda': 'agenda', + 'start_datetime': event_date, + 'slug': 'event', + 'label': 'Event', + }, + 'check_status': {'foo': 'bar'}, + 'booking': {'foo': 'baz'}, + }, + ] + with pricing_data_event_patch as mock_pricing_data_event: + lines = utils.get_invoice_lines_for_user( + agendas=[agenda], + user_external_id='user:1', + subscriptions={ + 'agenda': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-01-01', + 'date_end': '2023-01-01', + }, + ], + }, + date_start=datetime.date(2022, 1, 1), + date_end=datetime.date(2023, 1, 1), + ) + assert len(lines) == 0 + assert mock_pricing_data_event.call_args_list == [] + + +@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_pricing_error(mock_pricing_data_event, mock_status): + agenda = Agenda.objects.create(label='Agenda') + pricing = Pricing.objects.create(label='Foo bar') + agenda_pricing = AgendaPricing.objects.create( + pricing=pricing, + date_start=datetime.date(year=2022, month=9, day=1), + date_end=datetime.date(year=2022, month=10, day=1), + ) + agenda_pricing.agendas.add(agenda) + + mock_pricing_data_event.side_effect = [ + {'foo1': 'bar1', 'pricing': 1}, + PricingError(), + {'foo3': 'bar3', 'pricing': 3}, + ] + mock_status.return_value = [ + { + 'event': { + 'agenda': 'agenda', + 'start_datetime': '2022-09-01T12:00:00+02:00', + 'slug': 'event-1', + 'label': 'Event 1', + }, + 'check_status': {'foo': 'bar'}, + 'booking': {'foo': 'baz'}, + }, + { + 'event': { + 'agenda': 'agenda', + 'start_datetime': '2022-09-02T12:00:00+02:00', + 'slug': 'event-2', + 'label': 'Event 2', + }, + 'check_status': {'foo': 'bar'}, + 'booking': {'foo': 'baz'}, + }, + { + 'event': { + 'agenda': 'agenda', + 'start_datetime': '2022-09-03T12:00:00+02:00', + 'slug': 'event-3', + 'label': 'Event 3', + }, + 'check_status': {'foo': 'bar'}, + 'booking': {'foo': 'baz'}, + }, + ] + lines = utils.get_invoice_lines_for_user( + agendas=[agenda], + user_external_id='user:1', + subscriptions={ + 'agenda': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-09-01', + 'date_end': '2022-10-01', + }, + ], + }, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert len(lines) == 3 + line1, line2, line3 = lines + assert isinstance(line1, DraftInvoiceLine) + assert line1.invoice is None + assert line1.slug == 'agenda@event-1' + assert line1.label == 'Event 1' + assert line1.quantity == 1 + assert line1.unit_amount == 1 + assert line1.total_amount == 1 + assert line1.user_external_id == 'user:1' + assert line1.payer_external_id == 'user:1' + assert line1.event == { + 'agenda': 'agenda', + 'start_datetime': '2022-09-01T12:00:00+02:00', + 'slug': 'event-1', + 'label': 'Event 1', + } + assert line1.pricing_data == {'foo1': 'bar1', 'pricing': 1} + assert line1.status == 'success' + assert isinstance(line2, DraftInvoiceLine) + assert line2.invoice is None + assert line2.slug == 'agenda@event-2' + assert line2.label == 'Event 2' + assert line2.quantity == 0 + assert line2.unit_amount == 0 + assert line2.total_amount == 0 + assert line2.user_external_id == 'user:1' + assert line2.payer_external_id == 'user:1' + assert line2.event == { + 'agenda': 'agenda', + 'start_datetime': '2022-09-02T12:00:00+02:00', + 'slug': 'event-2', + 'label': 'Event 2', + } + assert line2.pricing_data == {'error': {}} + assert line2.status == 'error' + assert isinstance(line3, DraftInvoiceLine) + assert line3.invoice is None + assert line3.slug == 'agenda@event-3' + assert line3.label == 'Event 3' + assert line3.quantity == 1 + assert line3.unit_amount == 3 + assert line3.total_amount == 3 + assert line3.user_external_id == 'user:1' + assert line3.payer_external_id == 'user:1' + assert line3.event == { + 'agenda': 'agenda', + 'start_datetime': '2022-09-03T12:00:00+02:00', + 'slug': 'event-3', + 'label': 'Event 3', + } + assert line3.pricing_data == {'foo3': 'bar3', 'pricing': 3} + assert line3.status == 'success' + + +@mock.patch('lingo.invoicing.utils.get_invoice_lines_for_user') +def test_get_all_invoice_lines(mock_user_lines): + agenda1 = Agenda.objects.create(label='Agenda 1') + agenda2 = Agenda.objects.create(label='Agenda 2') + Agenda.objects.create(label='Agenda 3') + + line1 = DraftInvoiceLine.objects.create( + quantity=0, + unit_amount=0, + total_amount=0, + ) + line2 = DraftInvoiceLine.objects.create( + quantity=0, + unit_amount=0, + total_amount=0, + ) + line3 = DraftInvoiceLine.objects.create( + quantity=0, + unit_amount=0, + total_amount=0, + ) + + mock_user_lines.side_effect = [[line1, line2], [line3]] + + # no subscriptions + assert ( + utils.get_all_invoice_lines( + agendas=[agenda1, agenda2], + subscriptions={}, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + == [] + ) + assert mock_user_lines.call_args_list == [] + + # with subscriptions + assert utils.get_all_invoice_lines( + agendas=[agenda1, agenda2], + subscriptions={ + 'user:1': {'foo': 'bar1'}, + 'user:2': {'foo': 'bar2'}, + }, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) == [line1, line2, line3] + assert mock_user_lines.call_args_list == [ + mock.call( + agendas=[agenda1, agenda2], + user_external_id='user:1', + subscriptions={'foo': 'bar1'}, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ), + mock.call( + agendas=[agenda1, agenda2], + user_external_id='user:2', + subscriptions={'foo': 'bar2'}, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ), + ] + + +def test_generate_invoices_from_lines(): + regie1 = Regie.objects.create(label='Regie 1') + regie2 = Regie.objects.create(label='Regie 2') + agenda1 = Agenda.objects.create(label='Agenda 1', regie=regie1) + agenda2 = Agenda.objects.create(label='Agenda 2', regie=regie2) + agenda3 = Agenda.objects.create(label='Agenda 3', regie=regie1) + agenda4 = Agenda.objects.create(label='Agenda 4') # regie not configured + + line_error = DraftInvoiceLine.objects.create( + event={'agenda': 'agenda-1'}, + quantity=0, + unit_amount=0, + total_amount=0, + user_external_id='user:1', + payer_external_id='user:1', + status='error', + ) + line1 = DraftInvoiceLine.objects.create( + event={'agenda': 'agenda-1'}, + quantity=1, + unit_amount=1, + total_amount=1, + user_external_id='user:1', + payer_external_id='user:1', + status='success', + ) + line2 = DraftInvoiceLine.objects.create( + event={'agenda': 'agenda-1'}, + quantity=1, + unit_amount=2, + total_amount=2, + user_external_id='user:1', + payer_external_id='user:1', + status='success', + ) + line3 = DraftInvoiceLine.objects.create( + event={'agenda': 'agenda-2'}, + quantity=1, + unit_amount=3, + total_amount=3, + user_external_id='user:1', + payer_external_id='user:1', + status='success', + ) + line4 = DraftInvoiceLine.objects.create( + event={'agenda': 'agenda-2'}, + quantity=1, + unit_amount=4, + total_amount=4, + user_external_id='user:1', + payer_external_id='user:2', + status='success', + ) + line5 = DraftInvoiceLine.objects.create( + event={'agenda': 'agenda-3'}, + quantity=1, + unit_amount=5, + total_amount=5, + user_external_id='user:2', + payer_external_id='user:1', + status='success', + ) + DraftInvoiceLine.objects.create( # not used for generation + event={'agenda': 'agenda-3'}, + quantity=1, + unit_amount=5, + total_amount=5, + user_external_id='user:2', + payer_external_id='user:1', + status='success', + ) + line6 = DraftInvoiceLine.objects.create( + event={'agenda': 'agenda-4'}, # regie not configured + quantity=1, + unit_amount=6, + total_amount=6, + user_external_id='user:1', + payer_external_id='user:1', + status='success', + ) + + invoices = utils.generate_invoices_from_lines( + agendas=[], + all_lines=[line_error, line1, line2, line3, line4, line5, line6], + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert len(invoices) == 0 + + invoices = utils.generate_invoices_from_lines( + agendas=[agenda1, agenda2, agenda3, agenda4], + all_lines=[line_error, line1, line2, line3, line4, line5, line6], + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + assert len(invoices) == 3 + invoice1, invoice2, invoice3 = invoices + # refresh total_amount field (triggered) + invoice1.refresh_from_db() + invoice2.refresh_from_db() + 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.date_issue == datetime.date(2022, 10, 1) + assert invoice1.regie == regie1 + assert invoice1.payer == 'user:1' + assert list(invoice1.lines.order_by('pk')) == [line1, line2, line5] + assert isinstance(invoice2, DraftInvoice) + assert invoice2.label == 'Invoice from 2022-09-01 to 2022-09-30' + assert invoice2.total_amount == 3 + assert invoice2.date_issue == datetime.date(2022, 10, 1) + assert invoice2.regie == regie2 + assert invoice2.payer == 'user:1' + assert list(invoice2.lines.order_by('pk')) == [line3] + assert isinstance(invoice3, DraftInvoice) + assert invoice3.label == 'Invoice from 2022-09-01 to 2022-09-30' + assert invoice3.total_amount == 4 + assert invoice3.date_issue == datetime.date(2022, 10, 1) + assert invoice3.regie == regie2 + assert invoice3.payer == 'user:2' + assert list(invoice3.lines.order_by('pk')) == [line4] + + +@mock.patch('lingo.invoicing.utils.get_agendas') +@mock.patch('lingo.invoicing.utils.get_all_subscriptions') +@mock.patch('lingo.invoicing.utils.get_all_invoice_lines') +@mock.patch('lingo.invoicing.utils.generate_invoices_from_lines') +def test_generate_invoices(mock_generate, mock_lines, mock_subscriptions, mock_agendas): + agenda1 = Agenda.objects.create(label='Agenda 1') + agenda2 = Agenda.objects.create(label='Agenda 2') + Agenda.objects.create(label='Agenda 3') + line1 = DraftInvoiceLine.objects.create( + event={'agenda': 'agenda-1'}, + quantity=1, + unit_amount=1, + total_amount=1, + user_external_id='user:1', + payer_external_id='user:1', + status='success', + ) + line2 = DraftInvoiceLine.objects.create( + event={'agenda': 'agenda-1'}, + quantity=1, + unit_amount=2, + total_amount=2, + user_external_id='user:1', + payer_external_id='user:1', + status='success', + ) + line3 = DraftInvoiceLine.objects.create( + event={'agenda': 'agenda-2'}, + quantity=1, + unit_amount=3, + total_amount=3, + user_external_id='user:1', + payer_external_id='user:1', + status='success', + ) + DraftInvoiceLine.objects.create( + event={'agenda': 'agenda-1'}, + quantity=1, + unit_amount=5, + total_amount=5, + user_external_id='user:2', + payer_external_id='user:1', + status='success', + ) + + mock_agendas.return_value = [agenda1, agenda2] + mock_subscriptions.return_value = {'foo': 'bar'} + mock_lines.return_value = [line1, line2, line3] + + # check only calls between functions + utils.generate_invoices(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)) + assert mock_agendas.call_args_list == [ + mock.call(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)) + ] + assert mock_subscriptions.call_args_list == [ + mock.call( + agendas=[agenda1, agenda2], + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + ] + assert mock_lines.call_args_list == [ + mock.call( + agendas=[agenda1, agenda2], + subscriptions={'foo': 'bar'}, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + ] + assert mock_generate.call_args_list == [ + mock.call( + agendas=[agenda1, agenda2], + all_lines=[line1, line2, line3], + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 10, 1), + ) + ] + + +def test_generate_invoices_cmd(): + with mock.patch( + 'lingo.invoicing.management.commands.generate_invoices.generate_invoices' + ) as mock_generate: + with pytest.raises(CommandError) as excinfo: + call_command('generate_invoices') + assert '%s' % excinfo.value == 'Error: the following arguments are required: date_start, date_end' + assert mock_generate.call_args_list == [] + + with pytest.raises(CommandError) as excinfo: + call_command('generate_invoices', 'bad-value', '2022-10-01') + assert '%s' % excinfo.value == 'Bad value "bad-value" for date_start' + assert mock_generate.call_args_list == [] + + with pytest.raises(CommandError) as excinfo: + call_command('generate_invoices', '2022-09-01', 'bad-value') + assert '%s' % excinfo.value == 'Bad value "bad-value" for date_end' + assert mock_generate.call_args_list == [] + + call_command('generate_invoices', '2022-09-01', '2022-10-01') + assert mock_generate.call_args_list == [ + mock.call(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)) + ] diff --git a/tests/invoicing/test_models.py b/tests/invoicing/test_models.py new file mode 100644 index 0000000..27c87fa --- /dev/null +++ b/tests/invoicing/test_models.py @@ -0,0 +1,136 @@ +import datetime + +import pytest + +from lingo.invoicing.models import DraftInvoice, DraftInvoiceLine, Invoice, InvoiceLine, Regie + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize('draft', [True, False]) +def test_invoice_total_amount(draft): + regie = Regie.objects.create() + invoice_model = DraftInvoice if draft else Invoice + line_model = DraftInvoiceLine if draft else InvoiceLine + + invoice = invoice_model.objects.create(date_issue=datetime.date.today(), regie=regie) + assert invoice.total_amount == 0 + invoice2 = invoice_model.objects.create(date_issue=datetime.date.today(), regie=regie) + assert invoice2.total_amount == 0 + + # line with error status, ignored + line = line_model.objects.create( + invoice=invoice, # with invoice + quantity=0, + unit_amount=0, + total_amount=0, + status='error', + ) + invoice.refresh_from_db() + assert invoice.total_amount == 0 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 + + # update line + line.unit_amount = 10 + line.quantity = 1 + line.total_amount = 10 + line.save() + invoice.refresh_from_db() + # still in error + assert invoice.total_amount == 0 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 + + # update line status + line.status = 'success' + line.save() + invoice.refresh_from_db() + assert invoice.total_amount == 10 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 + + # update other field + line.unit_amount = 12 # total amount is wrong + line.save() + invoice.refresh_from_db() + assert invoice.total_amount == 10 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 + + # update total_amount + line.total_amount = 12 + line.save() + invoice.refresh_from_db() + assert invoice.total_amount == 12 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 + + # create line with invoice, status success + line2 = line_model.objects.create( + invoice=invoice, + quantity=1, + unit_amount=20, + total_amount=20, + status='success', + ) + invoice.refresh_from_db() + assert invoice.total_amount == 32 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 + + # change invoice + line2.invoice = invoice2 + line2.save() + invoice.refresh_from_db() + assert invoice.total_amount == 12 + invoice2.refresh_from_db() + assert invoice2.total_amount == 20 + + # delete line + line2.delete() + invoice.refresh_from_db() + assert invoice.total_amount == 12 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 + + # create line without invoice, status success + line3 = line_model.objects.create( + quantity=1, + unit_amount=20, + total_amount=20, + status='success', + ) + invoice.refresh_from_db() + assert invoice.total_amount == 12 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 + + # set invoice + line3.invoice = invoice + line3.save() + invoice.refresh_from_db() + assert invoice.total_amount == 32 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 + + # reset invoice + line3.invoice = None + line3.save() + invoice.refresh_from_db() + assert invoice.total_amount == 12 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 + # no changes + line3.save() + invoice.refresh_from_db() + assert invoice.total_amount == 12 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 + + # delete line + line3.delete() + invoice.refresh_from_db() + assert invoice.total_amount == 12 + invoice2.refresh_from_db() + assert invoice2.total_amount == 0 -- 2.39.2 From b8f7c98661c3f13f5772c2d6ddbcab154e49df6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= Date: Thu, 1 Dec 2022 09:56:43 +0100 Subject: [PATCH 4/4] invoicing: reduce queysets (#71528) --- lingo/invoicing/utils.py | 33 ++++- lingo/pricing/models.py | 14 +- tests/invoicing/test_invoice_generation.py | 151 ++++++++++++++++++++- 3 files changed, 189 insertions(+), 9 deletions(-) diff --git a/lingo/invoicing/utils.py b/lingo/invoicing/utils.py index 9ac8657..7f3899e 100644 --- a/lingo/invoicing/utils.py +++ b/lingo/invoicing/utils.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import collections import datetime from django.test.client import RequestFactory @@ -54,7 +55,19 @@ def get_all_subscriptions(agendas, date_start, date_end): return all_subscriptions -def get_invoice_lines_for_user(agendas, user_external_id, subscriptions, date_start, date_end): +def get_invoice_lines_for_user( + agendas, agendas_pricings, user_external_id, subscriptions, date_start, date_end +): + def get_agenda_pricing(agendas_pricings_for_agenda, date_event): + # same logic as AgendaPricing.get_agenda_pricing + for agenda_pricing in agendas_pricings_for_agenda: + if agenda_pricing.date_start > date_event: + continue + if agenda_pricing.date_end <= date_event: + continue + return agenda_pricing + raise AgendaPricingNotFound + def get_subscription(subscriptions, event_date): # get subscription matching event_date for subscription in subscriptions: @@ -69,6 +82,13 @@ def get_invoice_lines_for_user(agendas, user_external_id, subscriptions, date_st if not agendas or not subscriptions: return [] + agendas_pricings_by_agendas = collections.defaultdict(list) + for agenda_pricing in agendas_pricings: + if agenda_pricing.flat_fee_schedule: + continue + for agenda in agenda_pricing.agendas.all(): + agendas_pricings_by_agendas[agenda.slug].append(agenda_pricing) + # get check status for user_external_id, on agendas, for the period check_status_list = get_check_status( agenda_slugs=list(subscriptions.keys()), @@ -98,9 +118,7 @@ def get_invoice_lines_for_user(agendas, user_external_id, subscriptions, date_st agenda = agendas_by_slug[serialized_event['agenda']] try: - agenda_pricing = AgendaPricing.get_agenda_pricing( - agenda=agenda, start_date=event_date, flat_fee_schedule=False - ) # XXX optimize SQL request + agenda_pricing = get_agenda_pricing(agendas_pricings_by_agendas.get(agenda.slug), event_date) pricing_data = agenda_pricing.get_pricing_data_for_event( request=request, agenda=agenda, @@ -155,11 +173,18 @@ def get_invoice_lines_for_user(agendas, user_external_id, subscriptions, date_st def get_all_invoice_lines(agendas, subscriptions, date_start, date_end): + agendas_pricings = ( + AgendaPricing.objects.filter(flat_fee_schedule=False) + .extra(where=["(date_start, date_end) OVERLAPS (%s, %s)"], params=[date_start, date_end]) + .prefetch_related('agendas', 'pricing__criterias', 'pricing__categories') + ) + lines = [] for user_external_id, user_subs in subscriptions.items(): # generate lines for each user lines += get_invoice_lines_for_user( agendas=agendas, + agendas_pricings=agendas_pricings, user_external_id=user_external_id, subscriptions=user_subs, date_start=date_start, diff --git a/lingo/pricing/models.py b/lingo/pricing/models.py index 26fc267..7bee270 100644 --- a/lingo/pricing/models.py +++ b/lingo/pricing/models.py @@ -507,12 +507,16 @@ class AgendaPricing(models.Model): def compute_pricing(self, context): criterias = {} categories = [] - # for each category (ordered) - for category in self.pricing.categories.all().order_by('pricingcriteriacategory__order'): + # for each category + for category in self.pricing.categories.all(): criterias[category.slug] = None categories.append(category.slug) # find the first matching criteria (criterias are ordered) - for criteria in self.pricing.criterias.filter(category=category, default=False): + for criteria in self.pricing.criterias.all(): + if criteria.category_id != category.pk: + continue + if criteria.default: + continue condition = criteria.compute_condition(context) if condition: criterias[category.slug] = criteria.slug @@ -520,7 +524,9 @@ class AgendaPricing(models.Model): if criterias[category.slug] is not None: continue # if no match, take default criteria if only once defined - default_criterias = self.pricing.criterias.filter(category=category, default=True) + default_criterias = [ + c for c in self.pricing.criterias.all() if c.default and c.category_id == category.pk + ] if len(default_criterias) > 1: raise MultipleDefaultCriteriaCondition(details={'category': category.slug}) if not default_criterias: diff --git a/tests/invoicing/test_invoice_generation.py b/tests/invoicing/test_invoice_generation.py index b3e6765..f6077e8 100644 --- a/tests/invoicing/test_invoice_generation.py +++ b/tests/invoicing/test_invoice_generation.py @@ -3,12 +3,14 @@ from unittest import mock import pytest from django.core.management import CommandError, call_command +from django.db import connection +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 DraftInvoice, DraftInvoiceLine, Regie -from lingo.pricing.models import AgendaPricing, Pricing, PricingError +from lingo.pricing.models import AgendaPricing, Criteria, CriteriaCategory, Pricing, PricingError pytestmark = pytest.mark.django_db @@ -205,6 +207,7 @@ def test_get_invoice_lines_for_user_check_status_error(mock_status): with pytest.raises(ChronoError): utils.get_invoice_lines_for_user( agendas=['foo'], + agendas_pricings=[], user_external_id='user:1', subscriptions={'foo': 'bar'}, date_start=datetime.date(2022, 9, 1), @@ -229,6 +232,7 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s assert ( utils.get_invoice_lines_for_user( agendas=[], + agendas_pricings=[agenda_pricing], user_external_id='user:1', subscriptions={ 'agenda-1': [ @@ -251,6 +255,7 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s assert ( utils.get_invoice_lines_for_user( agendas=[agenda1, agenda2], + agendas_pricings=[agenda_pricing], user_external_id='user:1', subscriptions={}, date_start=datetime.date(2022, 9, 1), @@ -266,6 +271,7 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s assert ( utils.get_invoice_lines_for_user( agendas=[agenda1, agenda2], + agendas_pricings=[agenda_pricing], user_external_id='user:1', subscriptions={ 'agenda-1': [ @@ -306,6 +312,7 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s assert ( utils.get_invoice_lines_for_user( agendas=[agenda1, agenda2], + agendas_pricings=[agenda_pricing], user_external_id='user:1', subscriptions={ 'unknown': [ @@ -385,6 +392,7 @@ def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_s ] lines = utils.get_invoice_lines_for_user( agendas=[agenda1, agenda2], + agendas_pricings=[agenda_pricing], user_external_id='user:1', subscriptions={ # matching dates @@ -569,6 +577,7 @@ def test_get_invoice_lines_for_user_check_status_subscriptions_dates(mock_pricin assert ( utils.get_invoice_lines_for_user( agendas=[agenda], + agendas_pricings=[agenda_pricing], user_external_id='user:1', subscriptions={ 'agenda': [ @@ -590,6 +599,7 @@ def test_get_invoice_lines_for_user_check_status_subscriptions_dates(mock_pricin assert ( utils.get_invoice_lines_for_user( agendas=[agenda], + agendas_pricings=[agenda_pricing], user_external_id='user:1', subscriptions={ 'agenda': [ @@ -610,6 +620,7 @@ def test_get_invoice_lines_for_user_check_status_subscriptions_dates(mock_pricin lines = utils.get_invoice_lines_for_user( agendas=[agenda], + agendas_pricings=[agenda_pricing], user_external_id='user:1', subscriptions={ 'agenda': [ @@ -700,6 +711,7 @@ def test_get_invoice_lines_for_user_check_status_agenda_pricing_dates(mock_statu mock_pricing_data_event.return_value = {'foo': 'bar', 'pricing': 42} lines = utils.get_invoice_lines_for_user( agendas=[agenda], + agendas_pricings=AgendaPricing.objects.all(), user_external_id='user:1', subscriptions={ 'agenda': [ @@ -734,6 +746,7 @@ def test_get_invoice_lines_for_user_check_status_agenda_pricing_dates(mock_statu mock_pricing_data_event.return_value = {'foo': 'bar', 'pricing': 42} lines = utils.get_invoice_lines_for_user( agendas=[agenda], + agendas_pricings=AgendaPricing.objects.all(), user_external_id='user:1', subscriptions={ 'agenda': [ @@ -767,6 +780,7 @@ def test_get_invoice_lines_for_user_check_status_agenda_pricing_dates(mock_statu with pricing_data_event_patch as mock_pricing_data_event: lines = utils.get_invoice_lines_for_user( agendas=[agenda], + agendas_pricings=AgendaPricing.objects.all(), user_external_id='user:1', subscriptions={ 'agenda': [ @@ -835,6 +849,7 @@ def test_get_invoice_lines_for_user_check_status_pricing_error(mock_pricing_data ] lines = utils.get_invoice_lines_for_user( agendas=[agenda], + agendas_pricings=[agenda_pricing], user_external_id='user:1', subscriptions={ 'agenda': [ @@ -952,6 +967,7 @@ def test_get_all_invoice_lines(mock_user_lines): assert mock_user_lines.call_args_list == [ mock.call( agendas=[agenda1, agenda2], + agendas_pricings=mock.ANY, user_external_id='user:1', subscriptions={'foo': 'bar1'}, date_start=datetime.date(2022, 9, 1), @@ -959,6 +975,7 @@ def test_get_all_invoice_lines(mock_user_lines): ), mock.call( agendas=[agenda1, agenda2], + agendas_pricings=mock.ANY, user_external_id='user:2', subscriptions={'foo': 'bar2'}, date_start=datetime.date(2022, 9, 1), @@ -967,6 +984,138 @@ def test_get_all_invoice_lines(mock_user_lines): ] +@mock.patch('lingo.invoicing.utils.get_check_status') +def test_get_all_invoice_lines_queryset(mock_status): + # don't mock get_pricing_data_for_event to check all querysets + category1 = CriteriaCategory.objects.create(label='Foo1', slug='foo1') + criteria1 = Criteria.objects.create(label='Bar1', slug='bar1', condition='True', category=category1) + category2 = CriteriaCategory.objects.create(label='Foo2', slug='foo2') + criteria2 = Criteria.objects.create(label='Bar2', slug='bar2', condition='True', category=category2) + + pricing1 = Pricing.objects.create(label='Foo bar 1') + pricing1.criterias.add(criteria1, criteria2) + pricing1.categories.add(category1, through_defaults={'order': 1}) + pricing1.categories.add(category2, through_defaults={'order': 2}) + pricing2 = Pricing.objects.create(label='Foo bar 2') + pricing2.criterias.add(criteria1, criteria2) + pricing2.categories.add(category1, through_defaults={'order': 1}) + pricing2.categories.add(category2, through_defaults={'order': 2}) + + agenda1 = Agenda.objects.create(label='Agenda 1') + agenda_pricing11 = AgendaPricing.objects.create( + pricing=pricing1, + date_start=datetime.date(year=2022, month=9, day=1), + date_end=datetime.date(year=2022, month=10, day=1), + ) + agenda_pricing11.agendas.add(agenda1) + agenda_pricing12 = AgendaPricing.objects.create( + pricing=pricing1, + date_start=datetime.date(year=2022, month=10, day=1), + date_end=datetime.date(year=2022, month=11, day=1), + ) + agenda_pricing12.agendas.add(agenda1) + + agenda2 = Agenda.objects.create(label='Agenda 2') + agenda_pricing21 = AgendaPricing.objects.create( + pricing=pricing2, + date_start=datetime.date(year=2022, month=9, day=1), + date_end=datetime.date(year=2022, month=10, day=1), + ) + agenda_pricing21.agendas.add(agenda2) + agenda_pricing22 = AgendaPricing.objects.create( + pricing=pricing2, + date_start=datetime.date(year=2022, month=10, day=1), + date_end=datetime.date(year=2022, month=11, day=1), + ) + agenda_pricing22.agendas.add(agenda2) + + mock_status.return_value = [ + { + 'event': { + 'agenda': 'agenda-1', + 'start_datetime': '2022-09-01T12:00:00+02:00', + 'slug': 'event', + 'label': 'Event', + }, + 'check_status': {'foo': 'bar'}, + 'booking': {'foo': 'baz'}, + }, + { + 'event': { + 'agenda': 'agenda-1', + 'start_datetime': '2022-10-01T12:00:00+02:00', + 'slug': 'event', + 'label': 'Event', + }, + 'check_status': {'foo': 'bar'}, + 'booking': {'foo': 'baz'}, + }, + { + 'event': { + 'agenda': 'agenda-2', + 'start_datetime': '2022-09-01T12:00:00+02:00', + 'slug': 'event', + 'label': 'Event', + }, + 'check_status': {'foo': 'bar'}, + 'booking': {'foo': 'baz'}, + }, + { + 'event': { + 'agenda': 'agenda-2', + 'start_datetime': '2022-10-01T12:00:00+02:00', + 'slug': 'event', + 'label': 'Event', + }, + 'check_status': {'foo': 'bar'}, + 'booking': {'foo': 'baz'}, + }, + ] + + with CaptureQueriesContext(connection) as ctx: + lines = utils.get_all_invoice_lines( + agendas=[agenda1, agenda2], + subscriptions={ + 'user:1': { + 'agenda-1': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-09-01', + 'date_end': '2022-11-01', + }, + ], + 'agenda-2': [ + { + 'user_external_id': 'user:1', + 'date_start': '2022-09-01', + 'date_end': '2022-11-01', + }, + ], + }, + 'user:2': { + 'agenda-1': [ + { + 'user_external_id': 'user:2', + 'date_start': '2022-09-01', + 'date_end': '2022-11-01', + }, + ], + 'agenda-2': [ + { + 'user_external_id': 'user:2', + 'date_start': '2022-09-01', + 'date_end': '2022-11-01', + }, + ], + }, + }, + date_start=datetime.date(2022, 9, 1), + date_end=datetime.date(2022, 11, 1), + ) + assert lines + assert len(ctx.captured_queries) == 7 + + def test_generate_invoices_from_lines(): regie1 = Regie.objects.create(label='Regie 1') regie2 = Regie.objects.create(label='Regie 2') -- 2.39.2