diff --git a/apps/activity/templates/activity/activity_entry.html b/apps/activity/templates/activity/activity_entry.html index d59a4c48..d778490f 100644 --- a/apps/activity/templates/activity/activity_entry.html +++ b/apps/activity/templates/activity/activity_entry.html @@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-3.0-or-later

{{ title }}

-
+
{% trans "Transfer" %} diff --git a/apps/treasury/admin.py b/apps/treasury/admin.py index 1db820b2..25b4f4cb 100644 --- a/apps/treasury/admin.py +++ b/apps/treasury/admin.py @@ -24,9 +24,7 @@ class RemittanceAdmin(admin.ModelAdmin): list_display = ('remittance_type', 'date', 'comment', 'count', 'amount', 'closed', ) def has_change_permission(self, request, obj=None): - if not obj: - return True - return not obj.closed and super().has_change_permission(request, obj) + return not obj or (not obj.closed and super().has_change_permission(request, obj)) @admin.register(SogeCredit, site=admin_site) diff --git a/apps/treasury/api/views.py b/apps/treasury/api/views.py index ee97e6ac..82a0ed1e 100644 --- a/apps/treasury/api/views.py +++ b/apps/treasury/api/views.py @@ -16,7 +16,7 @@ class InvoiceViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Invoice` objects, serialize it to JSON with the given serializer, then render it on /api/treasury/invoice/ """ - queryset = Invoice.objects.all() + queryset = Invoice.objects.order_by("id").all() serializer_class = InvoiceSerializer filter_backends = [DjangoFilterBackend] filterset_fields = ['bde', ] @@ -28,7 +28,7 @@ class ProductViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Product` objects, serialize it to JSON with the given serializer, then render it on /api/treasury/product/ """ - queryset = Product.objects.all() + queryset = Product.objects.order_by("invoice_id", "id").all() serializer_class = ProductSerializer filter_backends = [SearchFilter] search_fields = ['$designation', ] @@ -40,7 +40,7 @@ class RemittanceTypeViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `RemittanceType` objects, serialize it to JSON with the given serializer then render it on /api/treasury/remittance_type/ """ - queryset = RemittanceType.objects + queryset = RemittanceType.objects.order_by("id") serializer_class = RemittanceTypeSerializer @@ -50,7 +50,7 @@ class RemittanceViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Remittance` objects, serialize it to JSON with the given serializer, then render it on /api/treasury/remittance/ """ - queryset = Remittance.objects + queryset = Remittance.objects.order_by("id") serializer_class = RemittanceSerializer @@ -60,5 +60,5 @@ class SogeCreditViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `SogeCredit` objects, serialize it to JSON with the given serializer, then render it on /api/treasury/soge_credit/ """ - queryset = SogeCredit.objects + queryset = SogeCredit.objects.order_by("id") serializer_class = SogeCreditSerializer diff --git a/apps/treasury/forms.py b/apps/treasury/forms.py index 38da324d..c2461f76 100644 --- a/apps/treasury/forms.py +++ b/apps/treasury/forms.py @@ -16,21 +16,15 @@ class InvoiceForm(forms.ModelForm): """ def clean(self): + # If the invoice is locked, it can't be updated. if self.instance and self.instance.locked: for field_name in self.fields: self.cleaned_data[field_name] = getattr(self.instance, field_name) self.errors.clear() + self.add_error(None, _('This invoice is locked and can no longer be edited.')) return self.cleaned_data return super().clean() - def save(self, commit=True): - """ - If the invoice is locked, don't save it - """ - if not self.instance.locked: - super().save(commit) - return self.instance - class Meta: model = Invoice exclude = ('bde', 'date', 'tex', ) diff --git a/apps/treasury/models.py b/apps/treasury/models.py index 6d5b4021..762c7bb5 100644 --- a/apps/treasury/models.py +++ b/apps/treasury/models.py @@ -85,7 +85,7 @@ class Invoice(models.Model): old_invoice = Invoice.objects.filter(id=self.id) if old_invoice.exists(): - if old_invoice.get().locked: + if old_invoice.get().locked and not self._force_save: raise ValidationError(_("This invoice is locked and can no longer be edited.")) products = self.products.all() @@ -224,7 +224,7 @@ class Remittance(models.Model): def save(self, force_insert=False, force_update=False, using=None, update_fields=None): # Check if all transactions have the right type. - if self.transactions.filter(~Q(source=self.remittance_type.note)).exists(): + if self.transactions.exists() and self.transactions.filter(~Q(source=self.remittance_type.note)).exists(): raise ValidationError("All transactions in a remittance must have the same type") return super().save(force_insert, force_update, using, update_fields) diff --git a/note_kfet/static/img/Finalist.png b/apps/treasury/static/img/Finalist.png similarity index 100% rename from note_kfet/static/img/Finalist.png rename to apps/treasury/static/img/Finalist.png diff --git a/note_kfet/static/img/Kataclist.png b/apps/treasury/static/img/Kataclist.png similarity index 100% rename from note_kfet/static/img/Kataclist.png rename to apps/treasury/static/img/Kataclist.png diff --git a/note_kfet/static/img/Listorique.png b/apps/treasury/static/img/Listorique.png similarity index 100% rename from note_kfet/static/img/Listorique.png rename to apps/treasury/static/img/Listorique.png diff --git a/note_kfet/static/img/Monopolist.png b/apps/treasury/static/img/Monopolist.png similarity index 100% rename from note_kfet/static/img/Monopolist.png rename to apps/treasury/static/img/Monopolist.png diff --git a/note_kfet/static/img/Saperlistpopette.png b/apps/treasury/static/img/Saperlistpopette.png similarity index 100% rename from note_kfet/static/img/Saperlistpopette.png rename to apps/treasury/static/img/Saperlistpopette.png diff --git a/note_kfet/static/img/Satellist.png b/apps/treasury/static/img/Satellist.png similarity index 100% rename from note_kfet/static/img/Satellist.png rename to apps/treasury/static/img/Satellist.png diff --git a/apps/treasury/tables.py b/apps/treasury/tables.py index 14044f1c..9a72ecf3 100644 --- a/apps/treasury/tables.py +++ b/apps/treasury/tables.py @@ -34,7 +34,7 @@ class InvoiceTable(tables.Table): delete = tables.LinkColumn( 'treasury:invoice_delete', - args=[A('pk')], + args=[A('id')], verbose_name=_("delete"), text=_("Delete"), attrs={ diff --git a/apps/treasury/templates/treasury/invoice_list.html b/apps/treasury/templates/treasury/invoice_list.html index 32c1b1c1..d9cd8a3e 100644 --- a/apps/treasury/templates/treasury/invoice_list.html +++ b/apps/treasury/templates/treasury/invoice_list.html @@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% block content %}
-
+
{% trans "Invoice" %}s diff --git a/apps/treasury/templates/treasury/invoice_sample.tex b/apps/treasury/templates/treasury/invoice_sample.tex index 4e6342b0..d7ec7391 100644 --- a/apps/treasury/templates/treasury/invoice_sample.tex +++ b/apps/treasury/templates/treasury/invoice_sample.tex @@ -58,7 +58,7 @@ \parbox[b][\paperheight]{\paperwidth}{% \vfill \centering - {\transparent{0.1}\includegraphics[width=\textwidth]{../../static/img/{{ obj.bde }}}}% + {\transparent{0.1}\includegraphics[width=\textwidth]{../../apps/treasury/static/img/{{ obj.bde }}}}% \vfill } } diff --git a/apps/treasury/templates/treasury/remittance_list.html b/apps/treasury/templates/treasury/remittance_list.html index c400f18f..8ced1ad0 100644 --- a/apps/treasury/templates/treasury/remittance_list.html +++ b/apps/treasury/templates/treasury/remittance_list.html @@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% block content %}
-
+
{% trans "Invoice" %}s diff --git a/apps/treasury/templates/treasury/sogecredit_list.html b/apps/treasury/templates/treasury/sogecredit_list.html index c3862811..1eb1aba5 100644 --- a/apps/treasury/templates/treasury/sogecredit_list.html +++ b/apps/treasury/templates/treasury/sogecredit_list.html @@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% block content %}
-
+
{% trans "Invoice" %}s @@ -59,9 +59,6 @@ SPDX-License-Identifier: GPL-3.0-or-later function reloadTable() { let pattern = searchbar_obj.val(); - if (pattern === old_pattern || pattern === "") - return; - $("#credits_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + ( invalid_only_obj.is(':checked') ? "&valid=false" : "") + " #credits_table"); diff --git a/apps/treasury/tests/__init__.py b/apps/treasury/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/treasury/tests/test_treasury.py b/apps/treasury/tests/test_treasury.py new file mode 100644 index 00000000..15d35cb3 --- /dev/null +++ b/apps/treasury/tests/test_treasury.py @@ -0,0 +1,403 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.test import TestCase +from django.urls import reverse + +from member.models import Membership, Club +from note.models import SpecialTransaction, NoteSpecial, Transaction +from treasury.models import Invoice, Product, Remittance, RemittanceType, SogeCredit + + +class TestInvoices(TestCase): + """ + Check that invoices can be created and rendered properly. + """ + def setUp(self) -> None: + self.user = User.objects.create_superuser( + username="admintoto", + password="totototo", + email="admin@example.com", + ) + self.client.force_login(self.user) + sess = self.client.session + sess["permission_mask"] = 42 + sess.save() + + self.invoice = Invoice.objects.create( + id=1, + object="Object", + description="Description", + name="Me", + address="Earth", + acquitted=False, + ) + self.product = Product.objects.create( + invoice=self.invoice, + designation="Product", + quantity=3, + amount=3.14, + ) + + def test_admin_page(self): + """ + Display the invoice admin page. + """ + response = self.client.get(reverse("admin:index") + "treasury/invoice/") + self.assertEqual(response.status_code, 200) + + def test_invoices_list(self): + """ + Display the list of invoices. + """ + response = self.client.get(reverse("treasury:invoice_list")) + self.assertEqual(response.status_code, 200) + + def test_invoice_create(self): + """ + Try to create a new invoice. + """ + response = self.client.get(reverse("treasury:invoice_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:invoice_create"), data={ + "id": 42, + "object": "Same object", + "description": "Longer description", + "name": "Me and others", + "address": "Alwways earth", + "acquitted": True, + "products-0-designation": "Designation", + "products-0-quantity": 1, + "products-0-amount": 42, + "products-TOTAL_FORMS": 1, + "products-INITIAL_FORMS": 0, + "products-MIN_NUM_FORMS": 0, + "products-MAX_NUM_FORMS": 1000, + }) + self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200) + self.assertTrue(Invoice.objects.filter(object="Same object", id=42).exists()) + self.assertTrue(Product.objects.filter(designation="Designation", invoice_id=42).exists()) + self.assertTrue(Invoice.objects.get(id=42).tex) + + def test_invoice_update(self): + """ + Try to update an invoice. + """ + response = self.client.get(reverse("treasury:invoice_update", args=(self.invoice.id,))) + self.assertEqual(response.status_code, 200) + + data = { + "object": "Same object", + "description": "Longer description", + "name": "Me and others", + "address": "Always earth", + "acquitted": True, + "locked": True, + "products-0-designation": "Designation", + "products-0-quantity": 1, + "products-0-amount": 4200, + "products-1-designation": "Second designation", + "products-1-quantity": 5, + "products-1-amount": -1800, + "products-TOTAL_FORMS": 2, + "products-INITIAL_FORMS": 0, + "products-MIN_NUM_FORMS": 0, + "products-MAX_NUM_FORMS": 1000, + } + + response = self.client.post(reverse("treasury:invoice_update", args=(self.invoice.id,)), data=data) + self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200) + self.invoice.refresh_from_db() + self.assertTrue(Invoice.objects.filter(pk=1, object="Same object", locked=True).exists()) + self.assertTrue(Product.objects.filter(designation="Second designation", invoice_id=1).exists()) + + # Resend the same data, but the invoice is locked. + response = self.client.get(reverse("treasury:invoice_update", args=(self.invoice.id,))) + self.assertTrue(response.status_code, 200) + response = self.client.post(reverse("treasury:invoice_update", args=(self.invoice.id,)), data=data) + self.assertTrue(response.status_code, 200) + + def test_delete_invoice(self): + """ + Try to delete an invoice. + """ + response = self.client.get(reverse("treasury:invoice_delete", args=(self.invoice.id,))) + self.assertEqual(response.status_code, 200) + + # Can't delete a locked invoice + self.invoice.locked = True + self.invoice.save() + response = self.client.delete(reverse("treasury:invoice_delete", args=(self.invoice.id,))) + self.assertEqual(response.status_code, 403) + self.assertTrue(Invoice.objects.filter(pk=self.invoice.id).exists()) + + # Unlock invoice and truly delete it. + self.invoice.locked = False + self.invoice._force_save = True + self.invoice.save() + response = self.client.delete(reverse("treasury:invoice_delete", args=(self.invoice.id,))) + self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200) + self.assertFalse(Invoice.objects.filter(pk=self.invoice.id).exists()) + + def test_invoice_render_pdf(self): + """ + Generate the PDF file of an invoice. + """ + response = self.client.get(reverse("treasury:invoice_render", args=(self.invoice.id,))) + self.assertEqual(response.status_code, 200) + + def test_invoice_api(self): + """ + Load some API pages + """ + response = self.client.get("/api/treasury/invoice/") + self.assertEqual(response.status_code, 200) + response = self.client.get("/api/treasury/product/") + self.assertEqual(response.status_code, 200) + + +class TestRemittances(TestCase): + """ + Create some credits and close remittances. + """ + + fixtures = ('initial',) + + def setUp(self) -> None: + self.user = User.objects.create_superuser( + username="admintoto", + password="totototo", + email="admin@example.com", + ) + self.client.force_login(self.user) + sess = self.client.session + sess["permission_mask"] = 42 + sess.save() + + self.credit = SpecialTransaction.objects.create( + source=NoteSpecial.objects.get(special_type="Chèque"), + destination=self.user.note, + amount=4200, + reason="Credit", + last_name="TOTO", + first_name="Toto", + bank="Société générale", + ) + + self.second_credit = SpecialTransaction.objects.create( + source=self.user.note, + destination=NoteSpecial.objects.get(special_type="Chèque"), + amount=424200, + reason="Second credit", + last_name="TOTO", + first_name="Toto", + bank="Société générale", + ) + + self.remittance = Remittance.objects.create( + remittance_type=RemittanceType.objects.get(), + comment="Test remittance", + closed=False, + ) + self.credit.specialtransactionproxy.remittance = self.remittance + self.credit.specialtransactionproxy.save() + + def test_admin_page(self): + """ + Load the admin page. + """ + response = self.client.get(reverse("admin:index") + "treasury/remittance/") + self.assertEqual(response.status_code, 200) + + def test_remittances_list(self): + """ + Display the remittance list. + :return: + """ + response = self.client.get(reverse("treasury:remittance_list")) + self.assertEqual(response.status_code, 200) + + def test_remittance_create(self): + """ + Create a new Remittance. + """ + response = self.client.get(reverse("treasury:remittance_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:remittance_create"), data=dict( + remittance_type=RemittanceType.objects.get().pk, + comment="Created remittance", + )) + self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200) + self.assertTrue(Remittance.objects.filter(comment="Created remittance").exists()) + + def test_remittance_update(self): + """ + Update an existing remittance. + """ + response = self.client.get(reverse("treasury:remittance_update", args=(self.remittance.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:remittance_update", args=(self.remittance.pk,)), data=dict( + comment="Updated remittance", + )) + self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200) + self.assertTrue(Remittance.objects.filter(comment="Updated remittance").exists()) + + def test_remittance_close(self): + """ + Try to close an open remittance. + """ + response = self.client.get(reverse("treasury:remittance_update", args=(self.remittance.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:remittance_update", args=(self.remittance.pk,)), data=dict( + comment="Closed remittance", + close=True, + )) + self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200) + self.assertTrue(Remittance.objects.filter(comment="Closed remittance", closed=True).exists()) + + def test_remittance_link_transaction(self): + """ + Link a transaction to an open remittance. + """ + response = self.client.get(reverse("treasury:link_transaction", args=(self.credit.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:link_transaction", args=(self.credit.pk,)), data=dict( + remittance=self.remittance.pk, + last_name="Last Name", + first_name="First Name", + bank="Bank", + )) + self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200) + self.credit.refresh_from_db() + self.assertEqual(self.credit.last_name, "Last Name") + self.assertEqual(self.remittance.transactions.count(), 1) + + response = self.client.get(reverse("treasury:unlink_transaction", args=(self.credit.pk,))) + self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200) + + def test_invoice_api(self): + """ + Load some API pages + """ + response = self.client.get("/api/treasury/remittance_type/") + self.assertEqual(response.status_code, 200) + response = self.client.get("/api/treasury/remittance/") + self.assertEqual(response.status_code, 200) + + +class TestSogeCredits(TestCase): + """ + Check that credits from the Société générale are working correctly. + """ + + fixtures = ('initial',) + + def setUp(self) -> None: + self.user = User.objects.create_superuser( + username="admintoto", + password="totototo", + email="admin@example.com", + ) + self.client.force_login(self.user) + sess = self.client.session + sess["permission_mask"] = 42 + sess.save() + + self.kfet = Club.objects.get(name="Kfet") + self.bde = self.kfet.parent_club + + self.kfet_membership = Membership( + user=self.user, + club=self.kfet, + ) + self.kfet_membership._force_renew_parent = True + self.kfet_membership._soge = True + self.kfet_membership.save() + + def test_admin_page(self): + """ + Render the admin page. + """ + response = self.client.get(reverse("admin:index") + "treasury/sogecredit/") + self.assertEqual(response.status_code, 200) + + def test_sogecredit_list(self): + """ + Display the list of all credits. + """ + response = self.client.get(reverse("treasury:soge_credits")) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("treasury:soge_credits") + "?search=toto&valid=") + self.assertEqual(response.status_code, 200) + + def test_validate_soge_credit(self): + """ + Try to validate a credit. + """ + soge_credit = SogeCredit.objects.get(user=self.user) + + response = self.client.get(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), data=dict( + validate=True, + )) + self.assertRedirects(response, reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), 302, 200) + soge_credit.refresh_from_db() + self.assertTrue(soge_credit.valid) + self.user.note.refresh_from_db() + self.assertEqual(self.user.note.balance, 0) + self.assertEqual( + Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3) + self.assertTrue(self.user.profile.soge) + + def test_delete_soge_credit(self): + """ + Try to invalidate a credit. + """ + soge_credit = SogeCredit.objects.get(user=self.user) + + response = self.client.get(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,))) + self.assertEqual(response.status_code, 200) + + try: + self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), data=dict(delete=True)) + raise AssertionError("It is not possible to delete the soge credit until the note is not credited.") + except ValidationError: + pass + + SpecialTransaction.objects.create( + source=NoteSpecial.objects.get(special_type="Carte bancaire"), + destination=self.user.note, + amount=self.bde.membership_fee_paid + self.kfet.membership_fee_paid, + quantity=1, + reason="Registration is not complete, pliz pay", + last_name="TOTO", + first_name="Toto", + ) + + response = self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), + data=dict(delete=True)) + # 403 because no SogeCredit exists anymore, then a PermissionDenied is raised + self.assertRedirects(response, reverse("treasury:soge_credits"), 302, 403) + self.assertFalse(SogeCredit.objects.filter(pk=soge_credit.pk)) + self.user.note.refresh_from_db() + self.assertEqual(self.user.note.balance, 0) + self.assertEqual( + Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3) + self.assertFalse(self.user.profile.soge) + + def test_invoice_api(self): + """ + Load some API pages + """ + response = self.client.get("/api/treasury/soge_credit/") + self.assertEqual(response.status_code, 200) diff --git a/apps/treasury/views.py b/apps/treasury/views.py index c2265289..5889f8b5 100644 --- a/apps/treasury/views.py +++ b/apps/treasury/views.py @@ -60,6 +60,11 @@ class InvoiceCreateView(ProtectQuerysetMixin, ProtectedCreateView): return context + def get_form(self, form_class=None): + form = super().get_form(form_class) + del form.fields["locked"] + return form + def form_valid(self, form): ret = super().form_valid(form) @@ -134,6 +139,11 @@ class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): return context + def get_form(self, form_class=None): + form = super().get_form(form_class) + del form.fields["id"] + return form + def form_valid(self, form): ret = super().form_valid(form) @@ -165,6 +175,11 @@ class InvoiceDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView): model = Invoice extra_context = {"title": _("Delete invoice")} + def delete(self, request, *args, **kwargs): + if self.get_object().locked: + raise PermissionDenied(_("This invoice is locked and can't be deleted.")) + return super().delete(request, *args, **kwargs) + def get_success_url(self): return reverse_lazy('treasury:invoice_list') @@ -387,7 +402,7 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi if not request.user.is_authenticated: return self.handle_no_permission() - if not self.get_queryset().exists(): + if not super().get_queryset().exists(): raise PermissionDenied(_("You are not able to see the treasury interface.")) return super().dispatch(request, *args, **kwargs) diff --git a/note_kfet/static/js/transfer.js b/note_kfet/static/js/transfer.js index cbae7456..e22d2b3f 100644 --- a/note_kfet/static/js/transfer.js +++ b/note_kfet/static/js/transfer.js @@ -96,7 +96,7 @@ $(document).ready(function() { let source = $("#source_note"); let dest = $("#dest_note"); - $("#type_transfer").click(function() { + $("#type_transfer").change(function() { if (LOCK) return; @@ -117,7 +117,7 @@ $(document).ready(function() { location.hash = "transfer"; }); - $("#type_credit").click(function() { + $("#type_credit").change(function() { if (LOCK) return; @@ -146,7 +146,7 @@ $(document).ready(function() { location.hash = "credit"; }); - $("#type_debit").click(function() { + $("#type_debit").change(function() { if (LOCK) return;