diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index b4761f6a69..130ebd4590 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -35,7 +35,10 @@ src="{% static 'img/blank_image.png' %}"

{{ order.description }}

-
+
+ {% if roles.purchase_order.change %} - {% endif %} - - -
- +
+ {% if roles.purchase_order.add %} + + {% endif %} + + + +
+ +
@@ -154,6 +160,18 @@ $("#view-list").click(function() { $("#view-calendar").show(); }); +$("#order-print").click(function() { + var rows = $("#purchase-order-table").bootstrapTable('getSelections'); + + var orders = []; + + rows.forEach(function(row) { + orders.push(row.pk); + }); + + printPurchaseOrderReports(orders); +}) + $("#po-create").click(function() { launchModalForm("{% url 'po-create' %}", { diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 17db37927c..ac4ebcf2bc 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -45,6 +45,9 @@ src="{% static 'img/blank_image.png' %}"

{{ order.description }}

+ {% if roles.sales_order.change %} - {% endif %} - - -
- +
+ {% if roles.sales_order.add %} + + {% endif %} + + + +
+ +
@@ -156,10 +162,30 @@ loadSalesOrderTable("#sales-order-table", { url: "{% url 'api-so-list' %}", }); +$("#order-print").click(function() { + var rows = $("#sales-order-table").bootstrapTable('getSelections'); + + var orders = []; + + rows.forEach(function(row) { + orders.push(row.pk); + }); + + printSalesOrderReports(orders); +}) + $("#so-create").click(function() { launchModalForm("{% url 'so-create' %}", { follow: true, + secondary: [ + { + field: 'customer', + label: '{% trans "New Customer" %}', + title: '{% trans "Create new Customer" %}', + url: '{% url "customer-create" %}', + } + ] } ); }); diff --git a/InvenTree/report/admin.py b/InvenTree/report/admin.py index 2c008877cc..b76b8fab1b 100644 --- a/InvenTree/report/admin.py +++ b/InvenTree/report/admin.py @@ -7,6 +7,8 @@ from .models import ReportSnippet, ReportAsset from .models import TestReport from .models import BuildReport from .models import BillOfMaterialsReport +from .models import PurchaseOrderReport +from .models import SalesOrderReport class ReportTemplateAdmin(admin.ModelAdmin): @@ -30,3 +32,5 @@ admin.site.register(ReportAsset, ReportAssetAdmin) admin.site.register(TestReport, ReportTemplateAdmin) admin.site.register(BuildReport, ReportTemplateAdmin) admin.site.register(BillOfMaterialsReport, ReportTemplateAdmin) +admin.site.register(PurchaseOrderReport, ReportTemplateAdmin) +admin.site.register(SalesOrderReport, ReportTemplateAdmin) diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 313d4de88d..212c57bd7f 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -18,14 +18,19 @@ from stock.models import StockItem import build.models import part.models +import order.models from .models import TestReport from .models import BuildReport from .models import BillOfMaterialsReport +from .models import PurchaseOrderReport +from .models import SalesOrderReport from .serializers import TestReportSerializer from .serializers import BuildReportSerializer from .serializers import BOMReportSerializer +from .serializers import POReportSerializer +from .serializers import SOReportSerializer class ReportListView(generics.ListAPIView): @@ -113,6 +118,40 @@ class BuildReportMixin: return build.models.Build.objects.filter(pk__in=valid_ids) +class OrderReportMixin: + """ + Mixin for extracting order items from query params + + requires the OrderModel class attribute to be set! + """ + + def get_orders(self): + """ + Return a list of order objects + """ + + orders = [] + + params = self.request.query_params + + for key in ['order', 'order[]', 'orders', 'orders[]']: + if key in params: + orders = params.getlist(key, []) + break + + valid_ids = [] + + for o in orders: + try: + valid_ids.append(int(o)) + except (ValueError): + pass + + valid_orders = self.OrderModel.objects.filter(pk__in=valid_ids) + + return valid_orders + + class PartReportMixin: """ Mixin for extracting part items from query params @@ -481,14 +520,203 @@ class BuildReportPrint(generics.RetrieveAPIView, BuildReportMixin, ReportPrintMi return self.print(request, builds) +class POReportList(ReportListView, OrderReportMixin): + + OrderModel = order.models.PurchaseOrder + + queryset = PurchaseOrderReport.objects.all() + serializer_class = POReportSerializer + + def filter_queryset(self, queryset): + + queryset = super().filter_queryset(queryset) + + orders = self.get_orders() + + if len(orders) > 0: + """ + We wish to filter by purchase orders + + We need to compare the 'filters' string of each report, + and see if it matches against each of the specified orders. + + TODO: In the future, perhaps there is a way to make this more efficient. + """ + + valid_report_ids = set() + + for report in queryset.all(): + + matches = True + + # Filter string defined for the report object + try: + filters = InvenTree.helpers.validateFilterString(report.filters) + except: + continue + + for o in orders: + order_query = order.models.PurchaseOrder.objects.filter(pk=o.pk) + + try: + if not order_query.filter(**filters).exists(): + matches = False + break + except FieldError: + matches = False + break + + if matches: + valid_report_ids.add(report.pk) + else: + continue + + # Reduce queryset to only valid matches + queryset = queryset.filter(pk__in=[pk for pk in valid_report_ids]) + + return queryset + + +class POReportDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for a single PurchaseOrderReport object + """ + + queryset = PurchaseOrderReport.objects.all() + serializer_class = POReportSerializer + + +class POReportPrint(generics.RetrieveAPIView, OrderReportMixin, ReportPrintMixin): + """ + API endpoint for printing a PurchaseOrderReport object + """ + + OrderModel = order.models.PurchaseOrder + + queryset = PurchaseOrderReport.objects.all() + serializer_class = POReportSerializer + + def get(self, request, *args, **kwargs): + + orders = self.get_orders() + + return self.print(request, orders) + + +class SOReportList(ReportListView, OrderReportMixin): + + OrderModel = order.models.SalesOrder + + queryset = SalesOrderReport.objects.all() + serializer_class = SOReportSerializer + + def filter_queryset(self, queryset): + + queryset = super().filter_queryset(queryset) + + orders = self.get_orders() + + if len(orders) > 0: + """ + We wish to filter by purchase orders + + We need to compare the 'filters' string of each report, + and see if it matches against each of the specified orders. + + TODO: In the future, perhaps there is a way to make this more efficient. + """ + + valid_report_ids = set() + + for report in queryset.all(): + + matches = True + + # Filter string defined for the report object + try: + filters = InvenTree.helpers.validateFilterString(report.filters) + except: + continue + + for o in orders: + order_query = order.models.SalesOrder.objects.filter(pk=o.pk) + + try: + if not order_query.filter(**filters).exists(): + matches = False + break + except FieldError: + matches = False + break + + if matches: + valid_report_ids.add(report.pk) + else: + continue + + # Reduce queryset to only valid matches + queryset = queryset.filter(pk__in=[pk for pk in valid_report_ids]) + + return queryset + + +class SOReportDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for a single SalesOrderReport object + """ + + queryset = SalesOrderReport.objects.all() + serializer_class = SOReportSerializer + + +class SOReportPrint(generics.RetrieveAPIView, OrderReportMixin, ReportPrintMixin): + """ + API endpoint for printing a PurchaseOrderReport object + """ + + OrderModel = order.models.SalesOrder + + queryset = SalesOrderReport.objects.all() + serializer_class = SOReportSerializer + + def get(self, request, *args, **kwargs): + + orders = self.get_orders() + + return self.print(request, orders) + + report_api_urls = [ + # Purchase order reports + url(r'po/', include([ + # Detail views + url(r'^(?P\d+)/', include([ + url(r'print/', POReportPrint.as_view(), name='api-po-report-print'), + url(r'^$', POReportDetail.as_view(), name='api-po-report-detail'), + ])), + + # List view + url(r'^$', POReportList.as_view(), name='api-po-report-list'), + ])), + + # Sales order reports + url(r'so/', include([ + # Detail views + url(r'^(?P\d+)/', include([ + url(r'print/', SOReportPrint.as_view(), name='api-so-report-print'), + url(r'^$', SOReportDetail.as_view(), name='api-so-report-detail'), + ])), + + url(r'^$', SOReportList.as_view(), name='api-so-report-list'), + ])), + # Build reports url(r'build/', include([ # Detail views url(r'^(?P\d+)/', include([ url(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'), - url(r'^.*$', BuildReportDetail.as_view(), name='api-build-report-detail'), + url(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'), ])), # List view diff --git a/InvenTree/report/migrations/0014_purchaseorderreport_salesorderreport.py b/InvenTree/report/migrations/0014_purchaseorderreport_salesorderreport.py new file mode 100644 index 0000000000..ab734b7b48 --- /dev/null +++ b/InvenTree/report/migrations/0014_purchaseorderreport_salesorderreport.py @@ -0,0 +1,45 @@ +# Generated by Django 3.0.7 on 2021-03-10 05:46 + +import django.core.validators +from django.db import migrations, models +import report.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0013_testreport_include_installed'), + ] + + operations = [ + migrations.CreateModel( + name='PurchaseOrderReport', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')), + ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')), + ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')), + ('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')), + ('filters', models.CharField(blank=True, help_text='Purchase order query filters', max_length=250, validators=[report.models.validate_purchase_order_filters], verbose_name='Filters')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SalesOrderReport', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')), + ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')), + ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')), + ('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')), + ('filters', models.CharField(blank=True, help_text='Sales order query filters', max_length=250, validators=[report.models.validate_sales_order_filters], verbose_name='Filters')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index abbdea9025..a838396bf0 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -24,6 +24,7 @@ import build.models import common.models import part.models import stock.models +import order.models from InvenTree.helpers import validateFilterString @@ -94,6 +95,22 @@ def validate_build_report_filters(filters): return validateFilterString(filters, model=build.models.Build) +def validate_purchase_order_filters(filters): + """ + Validate filter string against PurchaseOrder model + """ + + return validateFilterString(filters, model=order.models.PurchaseOrder) + + +def validate_sales_order_filters(filters): + """ + Validate filter string against SalesOrder model + """ + + return validateFilterString(filters, model=order.models.SalesOrder) + + class WeasyprintReportMixin(WeasyTemplateResponseMixin): """ Class for rendering a HTML template to a PDF. @@ -383,6 +400,74 @@ class BillOfMaterialsReport(ReportTemplateBase): } +class PurchaseOrderReport(ReportTemplateBase): + """ + Render a report against a PurchaseOrder object + """ + + @classmethod + def getSubdir(cls): + return 'purchaseorder' + + filters = models.CharField( + blank=True, + max_length=250, + verbose_name=_('Filters'), + help_text=_('Purchase order query filters'), + validators=[ + validate_purchase_order_filters, + ] + ) + + def get_context_data(self, request): + + order = self.object_to_print + + return { + 'description': order.description, + 'lines': order.lines, + 'order': order, + 'reference': order.reference, + 'supplier': order.supplier, + 'prefix': common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_PREFIX'), + 'title': str(order), + } + + +class SalesOrderReport(ReportTemplateBase): + """ + Render a report against a SalesOrder object + """ + + @classmethod + def getSubdir(cls): + return 'salesorder' + + filters = models.CharField( + blank=True, + max_length=250, + verbose_name=_('Filters'), + help_text=_('Sales order query filters'), + validators=[ + validate_sales_order_filters + ] + ) + + def get_context_data(self, request): + + order = self.object_to_print + + return { + 'customer': order.customer, + 'description': order.description, + 'lines': order.lines, + 'order': order, + 'prefix': common.models.InvenTreeSetting.get_setting('SALESORDER_REFERENCE_PREFIX'), + 'reference': order.reference, + 'title': str(order), + } + + def rename_snippet(instance, filename): filename = os.path.basename(filename) diff --git a/InvenTree/report/serializers.py b/InvenTree/report/serializers.py index f0a449ae49..fa7de1a3ea 100644 --- a/InvenTree/report/serializers.py +++ b/InvenTree/report/serializers.py @@ -7,6 +7,7 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField from .models import TestReport from .models import BuildReport from .models import BillOfMaterialsReport +from .models import PurchaseOrderReport, SalesOrderReport class TestReportSerializer(InvenTreeModelSerializer): @@ -55,3 +56,35 @@ class BOMReportSerializer(InvenTreeModelSerializer): 'filters', 'enabled', ] + + +class POReportSerializer(InvenTreeModelSerializer): + + template = InvenTreeAttachmentSerializerField(required=True) + + class Meta: + model = PurchaseOrderReport + fields = [ + 'pk', + 'name', + 'description', + 'template', + 'filters', + 'enabled', + ] + + +class SOReportSerializer(InvenTreeModelSerializer): + + template = InvenTreeAttachmentSerializerField(required=True) + + class Meta: + model = SalesOrderReport + fields = [ + 'pk', + 'name', + 'description', + 'template', + 'filters', + 'enabled', + ] diff --git a/InvenTree/report/templates/report/inventree_build_order_base.html b/InvenTree/report/templates/report/inventree_build_order_base.html index 46e7544df5..6c8af84f1d 100644 --- a/InvenTree/report/templates/report/inventree_build_order_base.html +++ b/InvenTree/report/templates/report/inventree_build_order_base.html @@ -77,12 +77,9 @@ margin-top: 4cm; content: "v{{report_revision}} - {{ date.isoformat }}"; {% endblock %} -{% block bottom_center %} -content: "www.currawong.aero"; -{% endblock %} - {% block header_content %} - + +

diff --git a/InvenTree/report/templates/report/inventree_po_report.html b/InvenTree/report/templates/report/inventree_po_report.html new file mode 100644 index 0000000000..2be71f6c38 --- /dev/null +++ b/InvenTree/report/templates/report/inventree_po_report.html @@ -0,0 +1,116 @@ +{% extends "report/inventree_report_base.html" %} + +{% load i18n %} +{% load report %} +{% load barcode %} +{% load inventree_extras %} +{% load markdownify %} + +{% block page_margin %} +margin: 2cm; +margin-top: 4cm; +{% endblock %} + +{% block bottom_left %} +content: "v{{report_revision}} - {{ date.isoformat }}"; +{% endblock %} + +{% block bottom_center %} +content: "InvenTree v{% inventree_version %}"; +{% endblock %} + +{% block style %} + +.header-right { + text-align: right; + float: right; +} + +.logo { + height: 20mm; + vertical-align: middle; +} + +.thumb-container { + width: 32px; + display: inline; +} + + +.part-thumb { + max-width: 32px; + max-height: 32px; + display: inline; +} + +.part-text { + display: inline; +} + +table { + border: 1px solid #eee; + border-radius: 3px; + border-collapse: collapse; + width: 100%; + font-size: 80%; +} + +table td { + border: 1px solid #eee; +} + +table td.shrink { + white-space: nowrap +} + +table td.expand { + width: 99% +} + +{% endblock %} + +{% block header_content %} + + + +
+

{% trans "Purchase Order" %} {{ prefix }}{{ reference }}

+ {{ supplier.name }} +
+ +{% endblock %} + +{% block page_content %} + +

{% trans "Line Items" %}

+ + + + + + + + + + + + {% for line in lines.all %} + + + + + + + {% endfor %} + +
{% trans "Part" %}{% trans "Quantity" %}{% trans "Reference" %}{% trans "Note" %}
+
+ +
+
+ {{ line.part.part.full_name }} +
+
{% decimal line.quantity %}{{ line.reference }}{{ line.notes }}
+ + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/report/templates/report/inventree_so_report.html b/InvenTree/report/templates/report/inventree_so_report.html new file mode 100644 index 0000000000..255f0c6a50 --- /dev/null +++ b/InvenTree/report/templates/report/inventree_so_report.html @@ -0,0 +1,116 @@ +{% extends "report/inventree_report_base.html" %} + +{% load i18n %} +{% load report %} +{% load barcode %} +{% load inventree_extras %} +{% load markdownify %} + +{% block page_margin %} +margin: 2cm; +margin-top: 4cm; +{% endblock %} + +{% block bottom_left %} +content: "v{{report_revision}} - {{ date.isoformat }}"; +{% endblock %} + +{% block bottom_center %} +content: "InvenTree v{% inventree_version %}"; +{% endblock %} + +{% block style %} + +.header-right { + text-align: right; + float: right; +} + +.logo { + height: 20mm; + vertical-align: middle; +} + +.thumb-container { + width: 32px; + display: inline; +} + + +.part-thumb { + max-width: 32px; + max-height: 32px; + display: inline; +} + +.part-text { + display: inline; +} + +table { + border: 1px solid #eee; + border-radius: 3px; + border-collapse: collapse; + width: 100%; + font-size: 80%; +} + +table td { + border: 1px solid #eee; +} + +table td.shrink { + white-space: nowrap +} + +table td.expand { + width: 99% +} + +{% endblock %} + +{% block header_content %} + + + +
+

{% trans "Sales Order" %} {{ prefix }}{{ reference }}

+ {{ customer.name }} +
+ +{% endblock %} + +{% block page_content %} + +

{% trans "Line Items" %}

+ + + + + + + + + + + + {% for line in lines.all %} + + + + + + + {% endfor %} + +
{% trans "Part" %}{% trans "Quantity" %}{% trans "Reference" %}{% trans "Note" %}
+
+ +
+
+ {{ line.part.full_name }} +
+
{% decimal line.quantity %}{{ line.reference }}{{ line.notes }}
+ + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/report/templatetags/report.py b/InvenTree/report/templatetags/report.py index 9cd28139f2..8ff15ccdee 100644 --- a/InvenTree/report/templatetags/report.py +++ b/InvenTree/report/templatetags/report.py @@ -8,6 +8,7 @@ from django import template from django.conf import settings from django.utils.safestring import mark_safe +from company.models import Company from part.models import Part from stock.models import StockItem @@ -72,6 +73,39 @@ def part_image(part): return f"file://{path}" +@register.simple_tag() +def company_image(company): + """ + Return a fully-qualified path for a company image + """ + + # If in debug mode, return the URL to the image, not a local file + debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') + + if type(company) is Company: + img = company.image.name + else: + img = '' + + if debug_mode: + if img: + return os.path.join(settings.MEDIA_URL, img) + else: + return os.path.join(settings.STATIC_URL, 'img', 'blank_image.png') + + else: + path = os.path.join(settings.MEDIA_ROOT, img) + path = os.path.abspath(path) + + if not os.path.exists(path) or not os.path.isfile(path): + # Image does not exist + # Return the 'blank' image + path = os.path.join(settings.STATIC_ROOT, 'img', 'blank_image.png') + path = os.path.abspath(path) + + return f"file://{path}" + + @register.simple_tag() def internal_link(link, text): """ diff --git a/InvenTree/templates/js/order.js b/InvenTree/templates/js/order.js index 92b9db787c..fa60ebdf6d 100644 --- a/InvenTree/templates/js/order.js +++ b/InvenTree/templates/js/order.js @@ -138,9 +138,9 @@ function loadPurchaseOrderTable(table, options) { formatNoMatches: function() { return '{% trans "No purchase orders found" %}'; }, columns: [ { - field: 'pk', - title: 'ID', - visible: false, + title: '', + visible: true, + checkbox: true, switchable: false, }, { @@ -234,9 +234,9 @@ function loadSalesOrderTable(table, options) { formatNoMatches: function() { return '{% trans "No sales orders found" %}'; }, columns: [ { - field: 'pk', - title: 'ID', - visible: false, + title: '', + checkbox: true, + visible: true, switchable: false, }, { diff --git a/InvenTree/templates/js/report.js b/InvenTree/templates/js/report.js index 6b6ce39db9..741692c56c 100644 --- a/InvenTree/templates/js/report.js +++ b/InvenTree/templates/js/report.js @@ -247,3 +247,111 @@ function printBomReports(parts, options={}) { } ) } + + +function printPurchaseOrderReports(orders, options={}) { + /** + * Print PO reports for the provided purchase order(s) + */ + + if (orders.length == 0) { + showAlertDialog( + '{% trans "Select Purchase Orders" %}', + '{% trans "Purchase Order(s) must be selected before printing report" %}', + ); + + return; + } + + // Request avaiable report templates + inventreeGet( + '{% url "api-po-report-list" %}', + { + enabled: true, + orders: orders, + }, + { + success: function(response) { + if (response.length == 0) { + showAlertDialog( + '{% trans "No Reports Found" %}', + '{% trans "No report templates found which match selected orders" %}', + ); + + return; + } + + // Select report template + selectReport( + response, + orders, + { + success: function(pk) { + var href = `/api/report/po/${pk}/print/?`; + + orders.forEach(function(order) { + href += `order=${order}&`; + }); + + window.location.href = href; + } + } + ) + } + } + ) +} + + +function printSalesOrderReports(orders, options={}) { + /** + * Print SO reports for the provided purchase order(s) + */ + + if (orders.length == 0) { + showAlertDialog( + '{% trans "Select Sales Orders" %}', + '{% trans "Sales Order(s) must be selected before printing report" %}', + ); + + return; + } + + // Request avaiable report templates + inventreeGet( + '{% url "api-so-report-list" %}', + { + enabled: true, + orders: orders, + }, + { + success: function(response) { + if (response.length == 0) { + showAlertDialog( + '{% trans "No Reports Found" %}', + '{% trans "No report templates found which match selected orders" %}', + ); + + return; + } + + // Select report template + selectReport( + response, + orders, + { + success: function(pk) { + var href = `/api/report/so/${pk}/print/?`; + + orders.forEach(function(order) { + href += `order=${order}&`; + }); + + window.location.href = href; + } + } + ) + } + } + ) +} diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 903bc10dfa..5c95abfe46 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -124,6 +124,8 @@ class RuleSet(models.Model): 'report_reportasset', 'report_reportsnippet', 'report_billofmaterialsreport', + 'report_purchaseorderreport', + 'report_salesorderreport', 'users_owner', # Third-party tables