diff --git a/.gitignore b/.gitignore index 16b8e66..d84e650 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,9 @@ cover/ *.mo *.pot +# Local data and documents +mysite/documents/* + # Django stuff: *.log local_settings.py diff --git a/README.md b/README.md index 260d65a..abf2db1 100644 --- a/README.md +++ b/README.md @@ -1 +1,350 @@ -# SCE_API \ No newline at end of file +# Installation +To install the application on your computer, open a command line and run: +```bash +pip install sce@git+https://github.com/CMLPlatform/SCE_API.git +``` + +# User Manual +## Key features +This software helps you to create Digital Product Passports in a streamlined way, to comply with the EU ESPR regulations. It is designed for manufacturers of consumer goods such as clothes, electronics, and batteries. +There are two parts: a web-based interface to conveniently construct datasets, and a API module that can be used to share data with other parties. + +## Recommended workflow +In the sections below, instructions are provided for using the data collector app. These instructions are ordered following the recommended workflow. It is possible to follow a different route. + +## Start page +[-> Try live](http://127.0.0.1:8000/dpp/welcome) + +On the start page, you find a list of recently modified production lines. A [production line](#production-line) is a sequence of [manufacturing processes](#process) that produce a consumer [product](#product). To continue working on a production line, click on its name. To create a new production line, click the green button. It is also possible to view a more detailed list of production lines, including older ones, by clicking 'See detailed list'. + +## Production line +[-> Try live](http://127.0.0.1:8000/dpp/productionline/) + +Creating a production line is simple: click on the button 'Add production line' when you are at the [start page](#start-page) or at the list of production lines. A [form](#forms) will be shown, which asks to enter some information about the new production line. +- **Name\***: The asterisk indicates that the name must be specified. +- **Description**: Add a description if you want, or leave it empty. +- **Final product\***: Select the product produced by this production line. Most likely, the product doesn't exist yet, so it isn't shown in the dropdown list. Instead, click the green **+** to create a new product. +- **Facility\***: Select the location where this production line is located. Click **+** if the [facility](#facility) is not in the list. +To go back to the list of production lines, simply follow the link in the [navigation pane](#navigation) on the left. + +When a production line is selected, the following information is shown: +- Detailed information about this production line, including a description, final product, and manufacturer. +- A depiction of [inputs and outputs](#inputs-and-outputs) of the production line, and processes contained in it. +- A list of [processes](#process). Click on one to see or edit it. Or click 'Add new process'. +- A list of [transport operations](#transport), indicating how each input or output has been transported. At first, no transport operations are specified. To create an initial set, click the green button 'Add transport operations'. + +## Facility +A facility is described by 3 fields: +- **Operator\***: Select the [company](#company) that operates this production line. Click **+** if your company is not in the list. +- **Country\***: Select the country where this facility is located. +- **Address\***: Specify the address. + +Multiple production lines can refer to the same facility. The above information needs to be provided only once. + +## Company +- **Name\*** +- **Address** +- **Country\*** +- **Contact email**: Customer support address. +- **Website** +- **Legal documents**: Attach official legal documentation associated with the company. This may include licenses, registration papers, permits, or other legally mandated certificates. +- **VAT number** + +## Product +A 'product', can by any good or item that can be purchased on a market, both consumer goods and intermediate products. There are four different types of products: +- **Product model**: describes a specific model or version of a product. All items of a product model share the same design, weight, and manuals. +- **Product batch**: describes a specific batch of products, indicated by a batch number. All items of a product batch share the same supply chain (production processes). +- **Product item**: a unique product. When it is shipped, it is identical to other items from the same batch. After sales, a product item has its own history of use, repair, upgrades, etc. +- **Secondary product**: a subtype of product model, describing a product that is reused, refurbished, remanufactured, or repurposed. This is used to describe waste flows and non-virgin inputs of a [production process](process). + +These four product types can be used to create DPPs with any level of detail. In case all product batches are the same, it suffices to specify the product model. When each item is unique (custom production), then a product batch is defined for each item. + +A product model or batch can be described in further detail, see [product details](#product-details). + +## Product details +It is possible - and sometimes mandatory - to specify some details about a [product](#product). This can be done using two forms: **physical product properties** and **DPP details**. Physical properties are the product's weight, volumen and density. The DPP details must be specified for the (final) product for which you want to create a DPP. These DPP details are: +- **Importer**: If applicable, the legal entity that imports the product into the EU single market. Select or add the importing [Company](#company). +- **CPV code**: Common Procurement Vocabulary code. +- **GS1 GPC code**: Global Product Classification code. +- **Compliance documents\***: Attach one or more documents, according to the requirements of the product category. Select the appropriate [document type](#document). +- **Warranty period\***: Duration of the warranty, in years. Warranty conditions can be specified in a compliance document. +- **Spare parts availability duration\***: Guaranteed availability of spare parts, in years. +- **Take-back system\***: Specify which system is in place for taking back used products. Select one of the options. + +## Process +Now it is time to add a production process to the production line. A process is described by: +- **Name\*** +- **Production line\***: The production line that it belongs to. (Automatically filled when you use the 'Add new process' button in a [production line](production-line)). +- **Main output**: The main product produced by this process. It could be an intermediate product or a final product. It is important that each product is only produced by a single process! Even if two processes produce a similar product, you need to define a separate product for each. +- **Amount**: The number of units of the main output produced by this process. If you set the amount to '10', then you need to specify all [inputs and outputs](#inputs-and-outputs) needed to produce 10 items of the main output. +- **Facility**: The location of this process. If you leave this empty, the location of the production line will be set. +- **Description**: Optional description. +- **Outsourced**: Check the box if the process is operated by an external company. This will be set automatically. + +After adding a process, you can see the following process details: +- Inputs to process: shows a list of inputs. The symbol indicates the input type: + - 🧩 Component of the product + - 🧃 Consumable + - 🔥 Electricity or heat + - ⚙️ Utility or equipment + - 🧑‍🔧 Service + - 📦 Packaging + - ⚗️ Reactant + - 🗑️ Waste (used as feedstock) + - ⛏️ Natural resource extraction +- Buttons to add a new input. +- Outputs of process: Shows the main product, waste flows, and emissions. +- Buttons to add a new output. +- Detailed information about the process + +To verify that all processes are connected properly, go back to the [production line](#production-line) page and check the process diagram. + +## Inputs and outputs +Inputs to processes and outputs of processes all fall in the category of Exchanges. Two types of exchanges exist: +- Product exchanges: refers to inputs and outputs of man-made products and goods, including energy cariers and even services. +- Environmental exchanges: refers to inputs of natural resources (directly extracted from the environment), and emissions of substances to the environment (e.g. to air or surface water). + +A typical manufacturing process mostly has product exchanges. For instance, tap water is a product because it is produced by a water supplier. Waste flows are also product exchanges, unless the material is dumped into the environment. + +However, combustion processes have many environmental exchanges, because multiple substances are emitted to the air. The most reliable way to quantify these emissions is through a chemical analysis of flue gases. Alternatively, it is possible to connect to an [average market process](#average-market-process) that describes the combustion process. These average market processes can be imported from an LCA database. Most LCA databases contain processes for common combustion activities such as power plants, boilers, and vehicles. The advantage of using these processes is that no important pollutant exchanges are omitted. + +It is important to specify the type and name of the input/output. It can be an (intermediate) product produced by your company or another company. If it is unavailable from the list, you have to create the product and the process that produced it. For environmental exchanges, an extensive list of substances is available to choose from. There is no need to create new substances. + +If the amount of input or output is uncertain, you can specify the uncertainty distribution. This is optional. Depending on the uncertainty type, fill the following: +- Uniform distribution (interval): minimum, maximum +- Normal distribution: mean, standard deviation +- Lognormal distribution: mean, standard deviation +- Triangular distribution: mode, minimum, maximum + +The uncertainty paramters follow the conventions of [Brightway2 uncertainty data](https://deepwiki.com/maximikos/Brightway2_Intro/4.6.1-understanding-uncertainty-data). + +## Average market process +An 'average market process' describes common activities, such as electricity production or the operation of a natural gas boiler. As a user, you should usually import these processes from an LCA database rather than creating them yourself. Average market processes can in turn link to products from other processes, thereby describing the whole supply chain and the associated environmental [exchanges](#inputs-and-outputs). + +## Transport +Transport operations are conveniently modeled in a separate section. This way, it is not needed to create a separate transport process for each product that is used by your manufacturing process. Instead, you can directly select products purchased from suppliers as [inputs and outputs](#inputs-and-outputs). + +Transport operations are always linke to a [production line](#production-line). From the detail page of a production line, you can: +- Create an initial set of transport operations +- Edit the transport details for one product +- Go to the full list of transport details + +It is best to create the transport operations after all processes and exchanges have been modeled. + +It is assumed that transport is only needed for products entering an leaving the production line. If transport occurs between two internal processes, you can add it manually by selecting a transport service as input. Automatically created transport entries can - and should - be edited to update the transport distance and mode of transport. + +## Compositions +To see the details of a [product](#product), click on 'View' in the [process](#process) that produces it. (It will be possible later to also access from the production line page.) On this page, you can see: +- Information about the product +- Origin: the manufacturer and the production process. +- Bill of materials (BoM): list of materials in the product. This will be empty at first. Hazardous and critical materials are labeled as such. +- Components with missing BoM: supports the completion of material info, see below. + +There are two ways to complete the BoM: +1. Add all the materials by clicking 'Add material' multiple times. Useful when the product consists solely of primary raw materials. +2. Go to the component(s) with missing BoM, add the composition following approach 1, go back to the original product and click 'Recalculate'. + +## Publishing +After you have created the [production processes](#process), [products](#product), and their [compositions](#compositions), it is time to compile all information for a publishable DPP. The publishing page will guide you through all steps needed to validate the data, calculate sustainability scores, and register the DPP. + +On the [production line page](#production-line), click the button 'Review & Publish DPP' at the bottom. Please make sure that the [facility](#facility) of the production line is specified! First of all, click the 'Edit' button to make changes to the metadata for publishing. + +Next, there are five steps to go through in order: +1. Check completeness: this will check if all required information on the product is available. +2. Aggregate manufacturing process: combine all processes of the production line, such that details of individual steps are not disclosed. +3. Compute concentrations and components: calculate the concentration of hazardous and critical materials, and list the product's components. +4. Create transport table: add missing [transport](#transport) information for all the inputs that have none. +5. Do Life Cycle Assessment: LCA calculations for [environmental assessment](#sustainability-evaluation). + +After running one or more steps, the status (success or failure) will be indicated. An error message explains the reason of eventual errors. You may need to make some changes before re-running failed steps. +When all five steps are completed, you can click 'Publish DPP'. This will create DPPs for the requested number of product items. **Congratulations!** + +## Sustainability evaluation +A DPP describes the sustainability aspects of a product. Many sustainability indicators need to be calculated using specialized software or assessment methods. A 'Sustainability evaluation' groups all measured indicator values that were determined on a specific date or by a specific organization. +Create a new evaluation by clicking 'Sustainability evaluations' in the [navigation panel](#navigation) and then clicking 'Add sustainability evaluation'. You are asked to enter details about the evaluation. Some of these details are LCA terminology. If you are unfamiliar with these, you can ask an environmental assessment agengy to complete the form, or search for the information in their report. +- **Product\***: Select the product model or batch that is being assessed. +- **Is environmental\***: Whether this is an environmental sustainability evaluation (LCA). +- **Functional amount\***: The quantified output of the product system used as the reference for the assessment (e.g. 1 piece of screwdriver, or 1000 sheets of paper). The unit is defined by the product selected above. +- **System boundaries**: Defines which life cycle stages are included in the assessment, such as raw material extraction, manufacturing, distribution, use, and end-of-life. Commonly used system boundaries are 'Cradle to gate' and 'Cradle to grave'. +- **Geographical scope**: The geographic region to which the data and assumptions related to the use phase and end-of-life phase apply. Optionally select one of the following: Global, European Union, country-specific, or Other. +- **Temporal scope\***: The time period for which the data and assumptions are valid, expressed as a specific year or a range of years. +- **Impact assessment method**: The environmental impact assessment methodology used to translate manufacturing data into sustainability indicators (e.g. EF 3.0, ReCiPe, ILCD, TRACI). +- **Software used**: The software tool used to perform LCA or social impact calculations (e.g. openLCA, GaBi, SimaPro, Umberto). +- **Allocation method**: The approach used to allocate environmental impacts among co-products or multiple functions of a process. The allocation method can be mass-based, energy-based, or price-based (economic allocation). +- **Assessment date\***: The date on which the evaluation was conducted or finalized. +- **Assessed by**: Name of the organization responsible for conducting the evaluation. Create a new organization if necessary. + +## Document +In various forms, documents can be selected or uploaded. These documents contain additional information such as certificates, that cannot be stored in form fields. Documents can and should be labeled as one of the following types: +- Technical document + - Technical drawing + - Safety sheet + - Conformity certificate + - Mass balance + - Energy balance + - Product data sheet +- Compliance document + - Compliance report + - Quality certificate + - Safety data sheet + - Legal document + - Labor compliance + - Quality Management System certificate + - Warranty information + - Spare parts availability + - Return and take-back +- Manual + - User manual + - Maintenance manual + - Installation guide + - End-of-life guidelines +- Label + - Voluntary label + - Energy label + - Ecolabel + - Circularity label + - Legal markings +- Other + +## Navigation +To enable easy navigation through the app, use the links provided by the navigation panel on the left: +- Production lines +- Products +- Importers #TODO +- Sustainability evaluations +- User profile #TODO + +## Forms +The app uses forms to create new items, such as products and manufacturing processes. +Required fields that must be filled are indicated with an asterisk, e.g. **Name\***. +Some fields ask to link to another item, such as the operator of a process. These fields can be recognized as a drop-down box with a green plus sign (**+**) next to it. If you already created the item you wan to link to, select it in the dropdown list. Otherwise, click the **+** to create it in a pop-up window. +After filling the form, click the 'Save' button at the bottom. In case there are any issues with the information, an error message will explain what went wrong and how to correct it. + +# API Manual +Full Digital Product Passports (DPPs) and parts of a DPP can be retrieved using the API functionality. + +A DPP is uniquely identified by its registration number. It can be accessed through **www.company-website.com/api/metadata/**. The API response follows the basic structure of the example below. Note that, for clarity, some 'branches' are left out (indicated by `[]` and `None`). + +```JSON +{ + 'registration_number': '5dc50ff4-d31d-45c5-9c1e-2bc9ba830c63', + 'issuer': {'id': 1, 'legal_documents': None, 'name': 'Test Certifier AG', 'address': 'Street Name 7', 'country': 'CH', 'contact_email': '', 'website': '', 'type': 'ngo'}, + 'reo': {'id': 2, 'legal_documents': None, 'name': 'Example Manufacturer GmbH', 'address': '', 'country': 'DE', 'contact_email': '', 'website': 'www.example.com', 'vat_number': 'DE812345678'}, + 'product_item': { + 'id': 1, + 'product_batch': { + 'id': 2, + 'properties': None, + 'concentration': [{ + 'material': { + 'id': 2, + 'name': 'Silver', + 'chemical_formula': 'Ag', + 'criticality_level': 'h', + 'origin_country': 'ID', + }, + 'fraction': 0.1, + }], + 'composed_of': [{'amount': 1, 'component': 2}], + 'details': { + 'compliance_documents': { + 'manual': ['/documents/manual_EN.pdf', '/documents/manual_IT.pdf'], + 'technical_drawing': ['/documents/design.jpg'], + }, + 'CPV_code': '', + 'GS1_GPC_code': '', + 'warranty_period': '5.0', + 'spare_parts_availability_duration': '10.0', + 'takeback_system': 'active', + 'importer': None, + }, + 'latest_sustainability_evaluation': None, + 'latest_circularity_evaluation': None, + 'model': { + 'id': 1, + 'properties': None, + 'concentration': [], + 'composed_of': [], + 'details': None, + 'latest_sustainability_evaluation': { + 'id': 1, + 'sustainability_score': [ + { + 'id': 1, + 'impact_indicator': 1, + 'impact_value': 9.2, + 'upstream_phase': 0.4, + 'manufacturing_phase': 0.3, + 'use_phase': 0.2, + 'end_of_life_phase': 0.1, + 'scope_1_2_3': 7.4, + }, + { + 'id': 2, + 'impact_indicator': 2, + 'impact_value': 9.2, + 'upstream_phase': 0.2, + 'manufacturing_phase': 0.3, + 'use_phase': 0.4, + 'end_of_life_phase': 0.1, + 'scope_1_2_3': 7.4, + }, + ], + 'functional_amount': 1.0, + 'system_boundaries': 'Cradle to gate', + 'geographical_scope': 'EU', + 'temporal_scope': '2024', + 'impact_assessment_method': 'EF 3.01', + 'software_used': '', + 'allocation_method': 'mass', + 'assessment_date': '2026-01-01', + 'assessed_by': 3, + }, + 'latest_circularity_evaluation': None, + 'name': 'Test Widget v2', + 'unit': 'pcs', + 'brand': '', + 'description': 'A widget with many functions', + 'unit_price': None, + 'taric_code': '01234567890128', + 'hs_code': '' + }, + 'batch_number': 202507001, + }, + 'service_events': [ + { + 'id': '782e5138-8cea-40da-86f0-692814e42206', + 'item_exchanges': [], + 'activity_data': {'name': 'Maintenance process', 'amount': 1.0, 'facility': UUID('ada17b3e-0948-44cc-be24-40fc23a7f663'), 'description': '', 'modified_at': '2026-02-24'}, + 'type': 'test', + 'date': '2026-02-24', + 'operator': 3, + }, + { + 'id': 'ea8119ca-2a11-4675-b32a-3073869b66c8', + 'item_exchanges': [{'amount': 1, 'item': 2}], + 'activity_data': {'name': 'Maintenance process', 'amount': 1.0, 'facility': UUID('ada17b3e-0948-44cc-be24-40fc23a7f663'), 'description': '', 'modified_at': '2026-02-24'}, + 'type': 'corrective', + 'date': '2026-02-24', + 'operator': 3, + } + ], + 'serial_number': 'WGT-20250715-0042', + 'GTIN_code': '', + 'production_date': '2026-02-20', + 'circularity': 'new', + }, + 'creation_date': '2026-02-20', + 'last_modified': '2026-02-20', + 'version': '1.0', + 'language': 'DE', + 'access_link': '', + 'access_policy': '', + 'access_log_enabled': True, + 'verification_type': 0, + 'credential_format': 'xml', + 'storage_location': 0, + 'audit_trail_mechanism': 0, + 'update_interval': 'A', +} +``` diff --git a/generate_viewsets.py b/generate_viewsets.py index 14d50ba..031f29e 100644 --- a/generate_viewsets.py +++ b/generate_viewsets.py @@ -87,7 +87,7 @@ def generate_urls(classes, output_file): if __name__ == "__main__": # Determine file paths relative to script location script_dir = os.path.dirname(os.path.abspath(__file__)) - models_file = os.path.join(script_dir, 'mysite/api/models.py') + models_file = os.path.join(script_dir, 'mysite/dpp/models.py') serial_file = models_file.replace('models.py', 'serializers.txt') view_file = models_file.replace('models.py', 'views.txt') url_file = models_file.replace('models.py', 'urls.txt') diff --git a/mysite/accounts/__init__.py b/mysite/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mysite/accounts/admin.py b/mysite/accounts/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/mysite/accounts/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/mysite/accounts/apps.py b/mysite/accounts/apps.py new file mode 100644 index 0000000..0cb51e6 --- /dev/null +++ b/mysite/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" diff --git a/mysite/accounts/migrations/__init__.py b/mysite/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mysite/accounts/models.py b/mysite/accounts/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/mysite/accounts/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/mysite/dpp/tests.py b/mysite/accounts/tests.py similarity index 100% rename from mysite/dpp/tests.py rename to mysite/accounts/tests.py diff --git a/mysite/accounts/urls.py b/mysite/accounts/urls.py new file mode 100644 index 0000000..4c5b1fc --- /dev/null +++ b/mysite/accounts/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import SignUpView, ProfileView + +app_name = "accounts" + +urlpatterns = [ + path("signup/", SignUpView.as_view(), name="signup"), + path('profile/', ProfileView.as_view(), name="profile"), +] diff --git a/mysite/accounts/views.py b/mysite/accounts/views.py new file mode 100644 index 0000000..59f0fa8 --- /dev/null +++ b/mysite/accounts/views.py @@ -0,0 +1,21 @@ +from django.contrib.auth.forms import UserCreationForm +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.models import User +from django.urls import reverse_lazy +from django.views.generic import CreateView, UpdateView + + +class SignUpView(CreateView): + form_class = UserCreationForm + success_url = reverse_lazy("login") + template_name = "registration/signup.html" + +class ProfileView(LoginRequiredMixin, UpdateView): + model = User + fields = ["username", "first_name", "last_name", "email"] + template_name = "registration/user_settings.html" + context_object_name = "user" + success_url = reverse_lazy("dpp:home") + + def get_object(self): + return self.request.user diff --git a/mysite/api/serializers.py b/mysite/api/serializers.py index 3f960cd..531e9da 100644 --- a/mysite/api/serializers.py +++ b/mysite/api/serializers.py @@ -1,173 +1,301 @@ from rest_framework import serializers -from dpp.models import Institution, Company, Importer, ServiceOperator, Metadata, Instruction, Document, Material, HazardousMaterial, CriticalRawMaterial, ProductType, Packaging, SecondaryProduct, Emission, Composition, Product, ProductionLine, Process, SharedProcess, ProductExchange, EnvExchange, BillOfMaterials, PackagingInfo, ServiceEvent, ServiceRecord, ReplacedComponents, EndOfLife, ImpactCategory, SustainablityEvaluation, SustainabilityScore, CircularityEvaluation, OldCircularityIndicator, CircularityIndicator, CircularityScore, CircularityEnabler, CircularityTracker +from dpp.models import * +from django_countries.serializers import CountryFieldMixin -class InstitutionSerializer(serializers.ModelSerializer): +class DocumentLinkSerializer(serializers.ModelSerializer): + file_url = serializers.SerializerMethodField() + class Meta: - model = Institution - fields = ['type'] + model = Document + fields=['file_url'] + + def get_file_url(self, document: Document): + request = self.context.get('request') + file_url = document.file.url + return request.build_absolute_uri(file_url) -class CompanySerializer(serializers.ModelSerializer): +class OrganizationSerializer(CountryFieldMixin, serializers.ModelSerializer): + legal_documents = DocumentLinkSerializer() class Meta: + model = Organization + fields = '__all__' + +class InstitutionSerializer(OrganizationSerializer): + class Meta(OrganizationSerializer.Meta): + model = Institution + +class CompanySerializer(OrganizationSerializer): + class Meta(OrganizationSerializer.Meta): model = Company - fields = ['vat_number'] -class ImporterSerializer(serializers.ModelSerializer): - class Meta: +class ImporterSerializer(CompanySerializer): + class Meta(CompanySerializer.Meta): model = Importer - fields = ['EORI_number'] class ServiceOperatorSerializer(serializers.ModelSerializer): class Meta: model = ServiceOperator fields = ['service_description'] -class MetadataSerializer(serializers.ModelSerializer): +class FacilitySerializer(CountryFieldMixin, serializers.ModelSerializer): + operator = CompanySerializer(read_only=True) class Meta: - model = Metadata - fields = ['registration_number', 'issuer', 'creation_date', 'last_modified', 'version', 'access_link', 'access_policy', 'access_log_enabled', 'verification_type', 'credential_format', 'storage_location', 'audit_trail_mechanism', 'update_interval'] + model = Facility + fields = ['uid', 'operator', 'address'] + +class InstructionSerializer(serializers.ModelSerializer): + class Meta: + model = Instruction + fields = ['label'] class DocumentSerializer(serializers.ModelSerializer): + issuer = InstitutionSerializer(read_only=True) + file_url = serializers.SerializerMethodField() + class Meta: model = Document - fields = ['file', 'type', 'instructions', 'language', 'file_type', 'upload_date'] + fields = ['file', 'type', 'issuer', 'instructions', 'language', 'issue_date', 'expiry_date', 'file_url'] + + def get_document_url(self): + request = self.context.get('request') + file_url = self.file.url + return request.build_absolute_uri(file_url) -class MaterialSerializer(serializers.ModelSerializer): +class ProductPropertiesSerializer(serializers.ModelSerializer): class Meta: - model = Material - fields = ['name', 'density', 'recycled_content', 'recyclable_percentage', 'biobased_percentage', 'reused_fraction', 'renewable_fraction'] + model = ProductProperties + exclude = ['product'] -class HazardousMaterialSerializer(serializers.ModelSerializer): - class Meta: - model = HazardousMaterial - fields = ['CAS_number', 'safety_instructions', 'substance_concentration', 'concentration_unit', 'substance_location'] +class DppDetailsSerializer(serializers.ModelSerializer): + compliance_documents = serializers.SerializerMethodField() + importer = ImporterSerializer() -class CriticalRawMaterialSerializer(serializers.ModelSerializer): class Meta: - model = CriticalRawMaterial - fields = ['supply_risk_level', 'substance_concentration', 'concentration_unit'] + model = DppDetails + exclude = ['product'] + + def get_compliance_documents(self, obj): + doc_dict = {} + for doc in obj.compliance_documents.all(): + doc_dict.setdefault(doc.type, []).append(doc.file.url) + #FIXME: could also use DocumentLinkSerializer + return doc_dict -class ProductTypeSerializer(serializers.ModelSerializer): +class EmissionSerializer(serializers.ModelSerializer): class Meta: - model = ProductType - fields = ['name', 'unit', 'description', 'unit_price', 'weight', 'weight_unit', 'volume', 'volume_unit', 'vendor_or_importer', 'origin', 'taric_code', 'hs_code', 'quality_compliance_documents', 'warranty_duration', 'spare_parts_availability_duration', 'takeback_system'] + model = Emission + fields = ['name', 'unit'] -class PackagingSerializer(serializers.ModelSerializer): +class ProductExchangeSerializer(serializers.ModelSerializer): class Meta: - model = Packaging - fields = '__all__' + model = ProductExchange + exclude = ['process'] -class SecondaryProductSerializer(serializers.ModelSerializer): +class EnvExchangeSerializer(serializers.ModelSerializer): + substance = EmissionSerializer(read_only=True) class Meta: - model = SecondaryProduct - fields = ['circularity', 'is_waste'] + model = EnvExchange + exclude = ['process'] -class EmissionSerializer(serializers.ModelSerializer): +class ActivitySerializer(serializers.ModelSerializer): + prod_exchanges = ProductExchangeSerializer() + env_exchanges = EnvExchangeSerializer() class Meta: - model = Emission - fields = ['name'] + model = Activity + fields = '__all__' -class CompositionSerializer(serializers.ModelSerializer): - class Meta: - model = Composition - fields = ['product', 'material', 'fraction'] +class ManufacturingProcessSerializer(ActivitySerializer): + class Meta(ActivitySerializer.Meta): + model = ManufacturingProcess + fields = ['name', 'amount', 'facility', 'description', 'modified_at'] + #, 'prod_exchanges', 'env_exchanges'] -class ProductSerializer(serializers.ModelSerializer): - class Meta: - model = Product - fields = ['product_type', 'DPP_metadata', 'serial_number', 'batch_number', 'CPV_code', 'GS1_GPC_code', 'GTIN_code', 'production_date'] +class BackgroundProcessSerializer(ManufacturingProcessSerializer): + class Meta(ManufacturingProcessSerializer.Meta): + model = BackgroundProcess + fields = ['name', 'amount', 'description', 'modified_at', 'database'] + +class ProcessSerializer(ActivitySerializer): + class Meta(ActivitySerializer.Meta): + model = Process class ProductionLineSerializer(serializers.ModelSerializer): + mass_balance = DocumentLinkSerializer() + energy_balance = DocumentLinkSerializer() class Meta: model = ProductionLine - fields = ['name', 'description', 'final_product', 'operator', 'modified_at'] + fields = ['name', 'description', 'final_product', 'facility', 'modified_at', 'mass_balance', 'energy_balance'] -class ProcessSerializer(serializers.ModelSerializer): +class AliasSerializer(serializers.ModelSerializer): class Meta: - model = Process - fields = ['production_line', 'name', 'is_outsourced', 'operator', 'functional_flow', 'created_at', 'modified_at'] + model = Alias + fields = ['product', 'user', 'alt_name'] -class SharedProcessSerializer(serializers.ModelSerializer): +class TransportSerializer(serializers.ModelSerializer): class Meta: - model = SharedProcess - fields = '__all__' + model = Transport + fields = ['production_line', 'product', 'distance', 'mode'] -class ProductExchangeSerializer(serializers.ModelSerializer): - class Meta: - model = ProductExchange - fields = ['product'] +class MaterialSerializer(CountryFieldMixin, serializers.ModelSerializer): + is_critical = serializers.Field() #FIXME: requires `fields = []`` -class EnvExchangeSerializer(serializers.ModelSerializer): class Meta: - model = EnvExchange - fields = ['substance', 'compartment'] + model = Material + exclude = ['density', 'recycled_fraction', 'recyclable_fraction', 'biobased_fraction', 'renewable_fraction'] -class BillOfMaterialsSerializer(serializers.ModelSerializer): +class HazardousMaterialSerializer(MaterialSerializer): + safety_instructions = DocumentLinkSerializer() + class Meta(MaterialSerializer.Meta): + model = HazardousMaterial + +class CompositionSerializer(serializers.ModelSerializer): + material = MaterialSerializer(read_only=True) class Meta: - model = BillOfMaterials - fields = ['product', 'component', 'amount', 'unit'] + model = Composition + exclude = ['id', 'product'] -class PackagingInfoSerializer(serializers.ModelSerializer): +class ConcentrationSerializer(serializers.ModelSerializer): + material = MaterialSerializer(read_only=True) class Meta: - model = PackagingInfo - fields = ['product', 'packaging', 'packaging_ratio'] + model = Concentration + exclude = ['id', 'product'] -class ServiceEventSerializer(serializers.ModelSerializer): +class ComponentSerializer(serializers.ModelSerializer): class Meta: - model = ServiceEvent - fields = ['service_id', 'product', 'operator', 'service_type', 'date', 'maintenance_plan'] + model = Component + exclude = ['id', 'product'] -class ServiceRecordSerializer(serializers.ModelSerializer): +class ItemExchangeSerializer(serializers.ModelSerializer): class Meta: - model = ServiceRecord - fields = ['description', 'service_event'] + model = ItemExchange + exclude = ['id', 'event'] -class ReplacedComponentsSerializer(serializers.ModelSerializer): +class LifeCycleEventSerializer(serializers.ModelSerializer): + item_exchanges = ItemExchangeSerializer(many=True, read_only=True) + activity_data = ManufacturingProcessSerializer() class Meta: - model = ReplacedComponents - fields = ['service_record', 'old_component', 'new_component'] + model = LifeCycleEvent + exclude = ['product'] -class EndOfLifeSerializer(serializers.ModelSerializer): +class InspectionEventSerializer(LifeCycleEventSerializer): + diagnostic_results = DocumentLinkSerializer() + class Meta(LifeCycleEventSerializer.Meta): + model = InspectionEvent + +class MaintenanceEventSerializer(LifeCycleEventSerializer): + maintenance_plan = DocumentLinkSerializer() + class Meta(LifeCycleEventSerializer.Meta): + model = MaintenanceEvent + +class DisassemblyEventSerializer(LifeCycleEventSerializer): + class Meta(LifeCycleEventSerializer.Meta): + model = DisassemblyEvent + +class IndicatorSetSerializer(serializers.ModelSerializer): class Meta: - model = EndOfLife - fields = ['service_record', 'treatment_type', 'affected_component'] + model = IndicatorSet + fields = ['name', 'start_date', 'end_date'] class ImpactCategorySerializer(serializers.ModelSerializer): class Meta: model = ImpactCategory - fields = ['name', 'description', 'unit', 'is_environmental'] + fields = ['name'] -class SustainablityEvaluationSerializer(serializers.ModelSerializer): +class ImpactIndicatorSerializer(serializers.ModelSerializer): class Meta: - model = SustainablityEvaluation - fields = ['product_line', 'functional_amount', 'system_boundaries', 'geographical_scope', 'temporal_scope', 'impact_assessment_method', 'software_used', 'allocation_method', 'assessment_date', 'assessed_by'] + model = ImpactIndicator + fields = ['method', 'description', 'unit', 'is_environmental', 'indicator_set', 'impact_category'] class SustainabilityScoreSerializer(serializers.ModelSerializer): class Meta: model = SustainabilityScore - fields = ['impact_category', 'evaluation', 'impact_value', 'upstream_phase', 'manufacturing_phase', 'use_phase', 'end_of_life_phase', 'scope_1_2_3'] + exclude = ['id', 'evaluation'] -class CircularityEvaluationSerializer(serializers.ModelSerializer): - class Meta: - model = CircularityEvaluation - fields = ['product', 'assessment_date', 'assessed_by'] +class SustainabilityEvaluationSerializer(serializers.ModelSerializer): + sustainability_score = SustainabilityScoreSerializer(many=True, read_only=True) + assessed_by = InstitutionSerializer(read_only=True) -class OldCircularityIndicatorSerializer(serializers.ModelSerializer): class Meta: - model = OldCircularityIndicator - fields = ['product', 'is_static', 'name', 'value', 'unit'] + model = SustainabilityEvaluation + exclude = ['id', 'product'] class CircularityIndicatorSerializer(serializers.ModelSerializer): class Meta: model = CircularityIndicator - fields = ['name', 'description', 'is_static', 'unit'] + fields = ['id', 'name', 'description', 'is_static', 'unit'] class CircularityScoreSerializer(serializers.ModelSerializer): class Meta: model = CircularityScore - fields = ['evaluation', 'indicator', 'value', 'modified_at', 'uncertainty', 'comment'] + fields = ['evaluation', 'indicator', 'value', 'uncertainty', 'comment'] + +class CircularityEvaluationSerializer(serializers.ModelSerializer): + circularility_score = CircularityScoreSerializer(many=True, read_only=True) + report = DocumentLinkSerializer() + assessed_by = InstitutionSerializer(read_only=True) + class Meta: + model = CircularityEvaluation + exclude = ['id', 'product'] + +class CircularityTrackerSerializer(CircularityScoreSerializer): + class Meta(CircularityScoreSerializer.Meta): + model = CircularityTracker + fields = ['name', 'description', 'functionality'] + +class FlowSerializer(serializers.ModelSerializer): + # Serialize foreign keys (left variable is related_name) + properties = ProductPropertiesSerializer(read_only=True) + concentration = ConcentrationSerializer(many=True, read_only=True) + composed_of = ComponentSerializer(many=True, read_only=True) + details = DppDetailsSerializer(read_only=True, allow_null=True) + # sustainability_evaluation = SustainabilityEvaluationSerializer(many=True, allow_null=True) + # circularity_evaluation = CircularityEvaluationSerializer(many=True, allow_null=True) + latest_sustainability_evaluation = serializers.SerializerMethodField() + latest_circularity_evaluation = serializers.SerializerMethodField() + manufacturing_info = ManufacturingProcessSerializer(allow_null=True) + + class Meta: + model = Flow + fields = '__all__' + depth = 1 + + def get_latest_sustainability_evaluation(self, obj): + latest = obj.sustainability_evaluation.order_by('-assessment_date').first() + if latest is None: + return None + return SustainabilityEvaluationSerializer(latest).data + + def get_latest_circularity_evaluation(self, obj): + latest = obj.circularity_evaluation.order_by('-assessment_date').first() + if latest is None: + return None + return CircularityEvaluationSerializer(latest).data + +class ProductModelSerializer(FlowSerializer): + class Meta(FlowSerializer.Meta): + model = ProductModel + +class SecondaryProductSerializer(ProductModelSerializer): + class Meta(ProductModelSerializer.Meta): + model = SecondaryProduct + +class ProductBatchSerializer(FlowSerializer): + model = ProductModelSerializer() + class Meta(FlowSerializer.Meta): + model = ProductBatch + +class ProductItemSerializer(serializers.ModelSerializer): + product_batch = ProductBatchSerializer(read_only=True) + service_events = LifeCycleEventSerializer(many=True, read_only=True) + class Meta: + model = ProductItem + fields = '__all__' -class CircularityEnablerSerializer(serializers.ModelSerializer): +class MetadataSerializer(serializers.ModelSerializer): + issuer = OrganizationSerializer(read_only=True) + reo = CompanySerializer(read_only=True) + product_item = ProductItemSerializer() class Meta: - model = CircularityEnabler - fields = ['type', 'description'] + model = Metadata + fields = '__all__' diff --git a/mysite/api/serializers.txt b/mysite/api/serializers.txt deleted file mode 100644 index 8749270..0000000 --- a/mysite/api/serializers.txt +++ /dev/null @@ -1,203 +0,0 @@ -from rest_framework import serializers -from .models import Organization, Institution, Company, Importer, ServiceOperator, Metadata, Instruction, Document, Material, HazardousMaterial, CriticalRawMaterial, ProductType, Packaging, SecondaryProduct, Emission, Composition, Product, ProductionLine, Process, SharedProcess, Exchange, ProductExchange, EnvExchange, BillOfMaterials, PackagingInfo, ServiceEvent, ServiceRecord, ReplacedComponents, Modification, CorrectiveMaintenance, EndOfLife, ImpactCategory, SustainablityEvaluation, SustainabilityScore, CircularityEvaluation, OldCircularityIndicator, CircularityIndicator, CircularityScore, CircularityEnabler, CircularityTracker - -class OrganizationSerializer(serializers.ModelSerializer): - class Meta: - model = Organization - fields = ['organization_id', 'name', 'address', 'contact_email', 'website', 'legal_documents'] - -class InstitutionSerializer(serializers.ModelSerializer): - class Meta: - model = Institution - fields = ['type'] - -class CompanySerializer(serializers.ModelSerializer): - class Meta: - model = Company - fields = ['vat_number'] - -class ImporterSerializer(serializers.ModelSerializer): - class Meta: - model = Importer - fields = ['EORI_number'] - -class ServiceOperatorSerializer(serializers.ModelSerializer): - class Meta: - model = ServiceOperator - fields = ['service_description'] - -class MetadataSerializer(serializers.ModelSerializer): - class Meta: - model = Metadata - fields = ['registration_number', 'issuer', 'creation_date', 'last_modified', 'version', 'access_link', 'access_policy', 'access_log_enabled', 'verification_type', 'credential_format', 'storage_location', 'audit_trail_mechanism', 'update_interval'] - -class InstructionSerializer(serializers.ModelSerializer): - class Meta: - model = Instruction - fields = ['label'] - -class DocumentSerializer(serializers.ModelSerializer): - class Meta: - model = Document - fields = ['file', 'type', 'instructions', 'language', 'file_type', 'upload_date'] - -class MaterialSerializer(serializers.ModelSerializer): - class Meta: - model = Material - fields = ['name', 'density', 'recycled_content', 'recyclable_percentage', 'biobased_percentage', 'reused_fraction', 'renewable_fraction'] - -class HazardousMaterialSerializer(serializers.ModelSerializer): - class Meta: - model = HazardousMaterial - fields = ['CAS_number', 'safety_instructions', 'substance_concentration', 'concentration_unit', 'substance_location'] - -class CriticalRawMaterialSerializer(serializers.ModelSerializer): - class Meta: - model = CriticalRawMaterial - fields = ['supply_risk_level', 'substance_concentration', 'concentration_unit'] - -class ProductTypeSerializer(serializers.ModelSerializer): - class Meta: - model = ProductType - fields = ['name', 'unit', 'description', 'unit_price', 'weight', 'weight_unit', 'volume', 'volume_unit', 'vendor_or_importer', 'origin', 'taric_code', 'hs_code', 'quality_compliance_documents', 'warranty_duration', 'spare_parts_availability_duration', 'takeback_system'] - -class PackagingSerializer(serializers.ModelSerializer): - class Meta: - model = Packaging - fields = '__all__' - -class SecondaryProductSerializer(serializers.ModelSerializer): - class Meta: - model = SecondaryProduct - fields = ['circularity', 'is_waste'] - -class EmissionSerializer(serializers.ModelSerializer): - class Meta: - model = Emission - fields = ['name'] - -class CompositionSerializer(serializers.ModelSerializer): - class Meta: - model = Composition - fields = ['product', 'material', 'fraction'] - -class ProductSerializer(serializers.ModelSerializer): - class Meta: - model = Product - fields = ['product_type', 'DPP_metadata', 'serial_number', 'batch_number', 'CPV_code', 'GS1_GPC_code', 'GTIN_code', 'production_date'] - -class ProductionLineSerializer(serializers.ModelSerializer): - class Meta: - model = ProductionLine - fields = ['name', 'description', 'final_product', 'operator', 'modified_at'] - -class ProcessSerializer(serializers.ModelSerializer): - class Meta: - model = Process - fields = ['production_line', 'name', 'is_outsourced', 'operator', 'functional_flow', 'created_at', 'modified_at'] - -class SharedProcessSerializer(serializers.ModelSerializer): - class Meta: - model = SharedProcess - fields = '__all__' - -class ExchangeSerializer(serializers.ModelSerializer): - class Meta: - model = Exchange - fields = ['process', 'exchange_type', 'amount', 'is_proxy', 'observed', 'uncertainty_type', 'loc', 'scale', 'shape', 'minimum', 'maximum'] - -class ProductExchangeSerializer(serializers.ModelSerializer): - class Meta: - model = ProductExchange - fields = ['product'] - -class EnvExchangeSerializer(serializers.ModelSerializer): - class Meta: - model = EnvExchange - fields = ['substance', 'compartment'] - -class BillOfMaterialsSerializer(serializers.ModelSerializer): - class Meta: - model = BillOfMaterials - fields = ['product', 'component', 'amount', 'unit'] - -class PackagingInfoSerializer(serializers.ModelSerializer): - class Meta: - model = PackagingInfo - fields = ['product', 'packaging', 'packaging_ratio'] - -class ServiceEventSerializer(serializers.ModelSerializer): - class Meta: - model = ServiceEvent - fields = ['service_id', 'product', 'operator', 'service_type', 'date', 'maintenance_plan'] - -class ServiceRecordSerializer(serializers.ModelSerializer): - class Meta: - model = ServiceRecord - fields = ['description', 'service_event'] - -class ReplacedComponentsSerializer(serializers.ModelSerializer): - class Meta: - model = ReplacedComponents - fields = ['service_record', 'old_component', 'new_component'] - -class ModificationSerializer(serializers.ModelSerializer): - class Meta: - model = Modification - fields = ['modification_category', 'affected_functionality', 'software_or_hardware'] - -class CorrectiveMaintenanceSerializer(serializers.ModelSerializer): - class Meta: - model = CorrectiveMaintenance - fields = ['root_cause', 'diagnostics_performed', 'corrective_action'] - -class EndOfLifeSerializer(serializers.ModelSerializer): - class Meta: - model = EndOfLife - fields = ['service_record', 'treatment_type', 'affected_component'] - -class ImpactCategorySerializer(serializers.ModelSerializer): - class Meta: - model = ImpactCategory - fields = ['name', 'description', 'unit', 'is_environmental'] - -class SustainablityEvaluationSerializer(serializers.ModelSerializer): - class Meta: - model = SustainablityEvaluation - fields = ['product_line', 'functional_amount', 'system_boundaries', 'geographical_scope', 'temporal_scope', 'impact_assessment_method', 'software_used', 'allocation_method', 'assessment_date', 'assessed_by'] - -class SustainabilityScoreSerializer(serializers.ModelSerializer): - class Meta: - model = SustainabilityScore - fields = ['impact_category', 'evaluation', 'impact_value', 'upstream_phase', 'manufacturing_phase', 'use_phase', 'end_of_life_phase', 'scope_1_2_3'] - -class CircularityEvaluationSerializer(serializers.ModelSerializer): - class Meta: - model = CircularityEvaluation - fields = ['product', 'assessment_date', 'assessed_by'] - -class OldCircularityIndicatorSerializer(serializers.ModelSerializer): - class Meta: - model = OldCircularityIndicator - fields = ['product', 'is_static', 'name', 'value', 'unit'] - -class CircularityIndicatorSerializer(serializers.ModelSerializer): - class Meta: - model = CircularityIndicator - fields = ['name', 'description', 'is_static', 'unit'] - -class CircularityScoreSerializer(serializers.ModelSerializer): - class Meta: - model = CircularityScore - fields = ['evaluation', 'indicator', 'value', 'modified_at', 'uncertainty', 'comment'] - -class CircularityEnablerSerializer(serializers.ModelSerializer): - class Meta: - model = CircularityEnabler - fields = ['type', 'description'] - -class CircularityTrackerSerializer(serializers.ModelSerializer): - class Meta: - model = CircularityTracker - fields = ['name', 'description', 'functionality'] - diff --git a/mysite/api/tests.py b/mysite/api/tests.py index 6305f86..d5047a9 100644 --- a/mysite/api/tests.py +++ b/mysite/api/tests.py @@ -1,3 +1,521 @@ from django.test import TestCase +from rest_framework.test import APIRequestFactory +from rest_framework import status +from datetime import date +from dpp.models import ( + Metadata, ProductItem, ProductBatch, ProductModel, + Institution, Company, ServiceOperator, Document, DppDetails, + Component, Concentration, Material, + InspectionEvent, DisassemblyEvent, MaintenanceEvent, ItemExchange, + ManufacturingProcess, Facility, ProductExchange, + SustainabilityEvaluation, SustainabilityScore, ImpactCategory, ImpactIndicator, +) +from api.serializers import MetadataSerializer, ProductItemSerializer, ProductModelSerializer, SustainabilityEvaluationSerializer + + +class MetadataSerializerTests(TestCase): + """ + Tests for MetadataSerializer (connects to most DPP models) + """ + + def setUp(self): + self.factory = APIRequestFactory() + + # Minimal institution (issuer) + self.issuer = Institution.objects.create( + name="Test Certifier AG", + type="ngo", + address="Street Name 7", + country="CH", + ) + + # Minimal company (REO = Responsible Economic Operator) + self.reo = Company.objects.create( + name="Example Manufacturer GmbH", + website="www.example.com", + country="DE", + vat_number="DE812345678", + ) + + # Documents + self.doc1 = Document.objects.create( + file="documents/LICENSE", + type="manual", + issuer=self.issuer, + language="EN", + issue_date=date(2025, 7, 10), + expiry_date=date(2026, 7, 10), + ) + self.doc2 = Document.objects.create( + file="documents/requirements.txt", + type="compliance", + issuer=self.issuer, + ) + + # Minimal product model (flow) + self.product_model = ProductModel.objects.create( + name="Test Widget v2", + unit="pcs", + taric_code="01234567890128", + ) + self.details = DppDetails.objects.create( + product=self.product_model, + warranty_period=10, + ) + self.details.compliance_documents.set([self.doc1, self.doc2]) + self.details.save() + + # Batch + self.batch = ProductBatch.objects.create( + model=self.product_model, + batch_number=202507001, + ) + + # Product item (instance) + self.item = ProductItem.objects.create( + product_batch=self.batch, + serial_number="WGT-20250715-0042", + ) + + # The metadata record we want to serialize + self.metadata = Metadata.objects.create( + product_item = self.item, + issuer=self.issuer, + reo=self.reo, + version="1.0", + language="DE", + credential_format="xml", + update_interval='A', + ) + + # Inject request into context to make build_absolute_uri work + self.request = self.factory.get("/api/") + self.serializer_context = {"request": self.request} + + def test_metadata_serializer_structure_and_basic_fields(self): + serializer = MetadataSerializer( + self.metadata, context=self.serializer_context + ) + data = serializer.data + + # Basic fields + self.assertEqual(data["registration_number"], str(self.metadata.pk)) + self.assertEqual(data["version"], self.metadata.version) + self.assertEqual(data["update_interval"], self.metadata.update_interval) + + # Nested product_item + self.assertIn("product_item", data) + item_data = data["product_item"] + self.assertEqual(item_data["serial_number"], self.item.serial_number) + + # Nested batch and model + self.assertIn("product_batch", item_data) + batch_data = item_data["product_batch"] + self.assertEqual(batch_data["batch_number"], self.batch.batch_number) + self.assertIn("model", batch_data) + model_data = batch_data["model"] + self.assertEqual(model_data["name"], "Test Widget v2") + + # Check DppDetails + expected_details = { + 'compliance_documents': { + 'manual': ['/documents/LICENSE'], + 'compliance': ['/documents/requirements.txt'] + }, + 'CPV_code': '', + 'GS1_GPC_code': '', + 'warranty_period': '10.0', + 'spare_parts_availability_duration': '0.0', + 'takeback_system': 'no', + 'importer': None, + } + self.assertDictEqual(model_data['details'], expected_details) + + def test_nested_issuer_and_reo(self): + serializer = MetadataSerializer( + self.metadata, context=self.serializer_context + ) + data = serializer.data + + # issuer (InstitutionSerializer) + self.assertIn("issuer", data) + issuer_data = data["issuer"] + self.assertEqual(issuer_data["name"], self.issuer.name) + self.assertEqual(issuer_data["country"], self.issuer.country) + # legal_documents should be present (even if empty) + self.assertIn("legal_documents", issuer_data) + + # reo (CompanySerializer → falls back to OrganizationSerializer) + self.assertIn("reo", data) + reo_data = data["reo"] + self.assertEqual(reo_data["name"], "Example Manufacturer GmbH") + self.assertIn("legal_documents", reo_data) + + def test_file_urls_are_absolute(self): + # Create a document linked somehow (e.g. via issuer) + doc = Document.objects.create( + file="documents/requirements.txt", + type="compliance", + issuer=self.issuer, + ) + + serializer = MetadataSerializer( + self.metadata, context=self.serializer_context + ) + data = serializer.data + + issuer_data = data["issuer"] + self.assertIn("legal_documents", issuer_data) + # Depending on how many documents → usually list + if issuer_data["legal_documents"]: + first_doc = issuer_data["legal_documents"][0] + self.assertTrue(first_doc["file_url"].startswith("http")) + self.assertIn(doc.file, first_doc["file_url"]) + +class LifeCycleEventSerializerTests(TestCase): + """ + Tests for LifeCycleEventSerializer and its child classes + """ + + def setUp(self): + self.api_factory = APIRequestFactory() + + # Create test objects + self.electricity = ProductModel.objects.create( + name="Electricity, low voltage, grid mix NL", + unit="kWh", + ) + self.product_model = ProductModel.objects.create( + name="Smartphone model W", + unit="pcs", + taric_code="012345678901", + ) + self.batch = ProductBatch.objects.create( + model=self.product_model, + batch_number=202507001, + ) + self.screen_model = ProductModel.objects.create( + name="Screen model W", + unit="pcs", + taric_code="012345678902", + ) + self.screen_batch = ProductBatch.objects.create( + model=self.screen_model, + batch_number=202507002, + ) + self.component = Component.objects.create( + product=self.product_model, + component=self.screen_model, + amount=1, + ) + self.glass = Material.objects.create( + name="Glass", + density=3, + ) + self.concentration = Concentration.objects.create( + product=self.screen_model, + material=self.glass, + fraction=1, + ) + self.company = Company.objects.create( + name="Example Manufacturer GmbH", + website="www.example.com", + country="DE", + vat_number="DE812345678", + ) + self.issuer = Institution.objects.create( + name="Test Certifier AG", + type="ngo", + address="Street Name 7", + country="CH", + ) + self.phone = ProductItem.objects.create( + product_batch=self.batch, + serial_number="PTT-20250715-0043", + ) + self.screen = ProductItem.objects.create( + product_batch=self.screen_batch, + serial_number="PTT-20250715-0041", + ) + self.metadata1 = Metadata.objects.create( + product_item=self.phone, + issuer=self.issuer, + reo=self.company, + version="1.0", + ) + self.metadata2 = Metadata.objects.create( + product_item=self.screen, + issuer=self.issuer, + reo=self.company, + version="1.0", + ) + self.operator = ServiceOperator.objects.create( + name="Repair Shop BV", + website="www.example.nl", + country="NL", + vat_number="NL012345678", + service_description="Repair of electronics, except computers." + ) + self.factory = Facility.objects.create( + operator=self.operator, + country='NL', + address="Industrieweg 1, Utrecht", + ) + self.service = ProductModel.objects.create( + name="Repair service", + unit="pcs", + ) + self.process = ManufacturingProcess.objects.create( + name="Maintenance process", + amount=1, + facility=self.factory, + functional_flow=self.service, + ) + self.energy_use = ProductExchange.objects.create( + process=self.process, + product=self.electricity, + amount=2.4, + direction='in', + is_observed=True, + type='ener', + ) + self.doc = Document.objects.create( + file="documents/Maintenance_plan.pdf", + type="maintenance", + issuer=self.operator, + language="EN", + issue_date=date(2025, 7, 10), + expiry_date=date(2026, 7, 10), + ) + self.inspection_event = InspectionEvent.objects.create( + operator=self.operator, + product=self.phone, + type='test', + activity_data=self.process, + ) + self.maintenance_event = MaintenanceEvent.objects.create( + operator=self.operator, + product=self.phone, + type='corrective', + activity_data=self.process, + maintenance_plan=self.doc, + description="Replacement of broken screen.", + software_or_hardware=False, + ) + self.exchange = ItemExchange.objects.create( + event=self.maintenance_event, + item=self.screen, + amount=1, + ) + + # Inject request into context to make build_absolute_uri work + self.request = self.api_factory.get("/api/") + self.serializer_context = {"request": self.request} + + def test_item_serializer_structure_and_basic_fields(self): + serializer = ProductItemSerializer( + self.phone, context=self.serializer_context + ) + data = serializer.data + + # Basic fields + self.assertEqual(data["serial_number"], self.phone.serial_number) + + # Nested service events + self.assertIn("service_events", data) + service_data = data["service_events"] + self.assertEqual(len(service_data), 2) + self.assertIn("activity_data", service_data[0]) + self.assertEqual(service_data[0]['date'], str(date.today())) + + +class ComponentAndConcentrationSerializerTests(TestCase): + """ + Tests for ComponentSerializer and ConcentrationSerializer + """ + + def setUp(self): + self.factory = APIRequestFactory() + + # Create product, component, and materials + self.product_model = ProductModel.objects.create( + name="Cheese grater XL", + unit="pcs", + ) + piece = ProductModel.objects.create( + name="Hand piece", + unit="pcs", + ) + steel = Material.objects.create(name="Steel", density=7800, criticality_level='m', origin_country='ID') + wood = Material.objects.create(name="Oak wood", density=1800) + + self.component = Component.objects.create( + product=self.product_model, + component=piece, + amount=1, + ) + self.concentration1 = Concentration.objects.create( + product=piece, + material=wood, + fraction=1, + ) + self.concentration2 = Concentration.objects.create( + product=self.product_model, + material=wood, + fraction=0.1, + ) + self.concentration3 = Concentration.objects.create( + product=self.product_model, + material=steel, + fraction=0.9, + ) + + def test_concentration_component_serializer(self): + serializer = ProductModelSerializer(self.product_model) + data = serializer.data + + components = data['composed_of'] + concentrations = data['concentration'] + + expected_comp = [{'amount': 1, 'component': 2}] + self.assertEqual(len(expected_comp), len(components)) + self.assertDictEqual(expected_comp[0], components[0]) + + expected_conc = [ + { + 'material': { + 'id': 2, + 'name': 'Oak wood', + 'chemical_formula': '', + 'criticality_level': '', + 'origin_country': '', + }, + 'fraction': 0.1, + }, + { + 'material': { + 'id': 1, + 'name': 'Steel', + 'chemical_formula': '', + 'criticality_level': 'm', + 'origin_country': 'ID', + }, + 'fraction': 0.9, + } + ] + self.assertListEqual(expected_conc, concentrations) + + +class SustainabilityEvaluationSerializerTests(TestCase): + """ + Tests for SustainabilityEvaluationSerializer, + CircularityEvaluationSerializer, and associated classes + """ + + def setUp(self): + self.factory = APIRequestFactory() + + self.product_model = ProductModel.objects.create( + name="Cheese grater XL", + unit="pcs", + ) + self.issuer = Institution.objects.create( + name="LCA consultants 0.2", + type="research", + address="Street Name 62", + country="CH", + ) + self.evaluation = SustainabilityEvaluation.objects.create( + product=self.product_model, + functional_amount=1, + system_boundaries="Cradle to gate", + geographical_scope="EU", + temporal_scope=2024, + impact_assessment_method="EF 3.01", + allocation_method='mass', + assessed_by=self.issuer, + ) + self.impact_category = ImpactCategory.objects.create( + name="Test indicators", + ) + self.gwp = ImpactIndicator.objects.create( + method="GWP 100", + unit="kg CO2-eq.", + is_environmental=True, + indicator_set=None, + impact_category=self.impact_category, + ) + self.gwp_score = SustainabilityScore.objects.create( + impact_indicator = self.gwp, + evaluation=self.evaluation, + impact_value=9.2, + upstream_phase=0.4, + manufacturing_phase=0.3, + use_phase=0.2, + end_of_life_phase=0.1, + scope_1_2_3=7.4, + ) + self.fwt = ImpactIndicator.objects.create( + method="FAETP 100", + unit="CTUe", + is_environmental=True, + indicator_set=None, + impact_category=self.impact_category, + ) + self.fwt_score = SustainabilityScore.objects.create( + impact_indicator = self.fwt, + evaluation=self.evaluation, + impact_value=9.2, + upstream_phase=0.2, + manufacturing_phase=0.3, + use_phase=0.4, + end_of_life_phase=0.1, + scope_1_2_3=7.4, + ) + + # Inject request into context to make build_absolute_uri work + self.request = self.factory.get("/api/") + self.serializer_context = {"request": self.request} + + def test_sustainability_evaluation_serializer(self): + serializer = SustainabilityEvaluationSerializer( + self.evaluation, context=self.serializer_context + ) + data = serializer.data + + expected_output = { + 'id': 1, + 'sustainability_score': [ + { + 'id': 1, + 'impact_indicator': 1, + 'impact_value': 9.2, + 'upstream_phase': 0.4, + 'manufacturing_phase': 0.3, + 'use_phase': 0.2, + 'end_of_life_phase': 0.1, + 'scope_1_2_3': 7.4, + }, + { + 'id': 2, + 'impact_indicator': 2, + 'impact_value': 9.2, + 'upstream_phase': 0.2, + 'manufacturing_phase': 0.3, + 'use_phase': 0.4, + 'end_of_life_phase': 0.1, + 'scope_1_2_3': 7.4, + }, + ], + 'functional_amount': 1.0, + 'system_boundaries': self.evaluation.system_boundaries, + 'geographical_scope': 'EU', + 'temporal_scope': '2024', + 'impact_assessment_method': 'EF 3.01', + 'software_used': '', + 'allocation_method': 'mass', + 'assessment_date': str(date.today()), + 'assessed_by': 1, + } + self.assertDictEqual(expected_output, data) -# Create your tests for the API here. diff --git a/mysite/api/urls.py b/mysite/api/urls.py index d98fe51..0bf352e 100644 --- a/mysite/api/urls.py +++ b/mysite/api/urls.py @@ -4,39 +4,47 @@ from rest_framework.routers import DefaultRouter router = DefaultRouter() +router.register(r'organization', views.OrganizationViewSet, basename='Organization') router.register(r'institution', views.InstitutionViewSet, basename='Institution') router.register(r'company', views.CompanyViewSet, basename='Company') router.register(r'importer', views.ImporterViewSet, basename='Importer') router.register(r'serviceoperator', views.ServiceOperatorViewSet, basename='ServiceOperator') router.register(r'metadata', views.MetadataViewSet, basename='Metadata') +router.register(r'instruction', views.InstructionViewSet, basename='Instruction') router.register(r'document', views.DocumentViewSet, basename='Document') router.register(r'material', views.MaterialViewSet, basename='Material') router.register(r'hazardousmaterial', views.HazardousMaterialViewSet, basename='HazardousMaterial') -router.register(r'criticalrawmaterial', views.CriticalRawMaterialViewSet, basename='CriticalRawMaterial') -router.register(r'producttype', views.ProductTypeViewSet, basename='ProductType') -router.register(r'packaging', views.PackagingViewSet, basename='Packaging') +router.register(r'productmodel', views.ProductModelViewSet, basename='ProductModel') +router.register(r'productbatch', views.ProductBatchViewSet, basename='ProductBatch') +router.register(r'productproperties', views.ProductPropertiesViewSet, basename='ProductProperties') +router.register(r'dppdetails', views.DppDetailsViewSet, basename='DppDetails') router.register(r'secondaryproduct', views.SecondaryProductViewSet, basename='SecondaryProduct') router.register(r'emission', views.EmissionViewSet, basename='Emission') router.register(r'composition', views.CompositionViewSet, basename='Composition') -router.register(r'product', views.ProductViewSet, basename='Product') +router.register(r'productitem', views.ProductItemViewSet, basename='ProductItem') +router.register(r'activity', views.ActivityViewSet, basename='Activity') +router.register(r'manufacturingprocess', views.ManufacturingProcessViewSet, basename='ManufacturingProcess') router.register(r'productionline', views.ProductionLineViewSet, basename='ProductionLine') router.register(r'process', views.ProcessViewSet, basename='Process') -router.register(r'sharedprocess', views.SharedProcessViewSet, basename='SharedProcess') +router.register(r'backgroundprocess', views.BackgroundProcessViewSet, basename='BackgroundProcess') router.register(r'productexchange', views.ProductExchangeViewSet, basename='ProductExchange') router.register(r'envexchange', views.EnvExchangeViewSet, basename='EnvExchange') -router.register(r'billofmaterials', views.BillOfMaterialsViewSet, basename='BillOfMaterials') -router.register(r'packaginginfo', views.PackagingInfoViewSet, basename='PackagingInfo') -router.register(r'serviceevent', views.ServiceEventViewSet, basename='ServiceEvent') -router.register(r'servicerecord', views.ServiceRecordViewSet, basename='ServiceRecord') -router.register(r'replacedcomponents', views.ReplacedComponentsViewSet, basename='ReplacedComponents') -router.register(r'endoflife', views.EndOfLifeViewSet, basename='EndOfLife') +router.register(r'alias', views.AliasViewSet, basename='Alias') +router.register(r'transport', views.TransportViewSet, basename='Transport') +router.register(r'lifecycleevent', views.LifeCycleEventViewSet, basename='LifeCycleEvent') +router.register(r'inspectionevent', views.InspectionEventViewSet, basename='InspectionEvent') +router.register(r'maintenanceevent', views.MaintenanceEventViewSet, basename='MaintenanceEvent') +router.register(r'itemexchange', views.ItemExchangeViewSet, basename='ItemExchange') +router.register(r'disassemblyevent', views.DisassemblyEventViewSet, basename='DisassemblyEvent') +router.register(r'indicatorset', views.IndicatorSetViewSet, basename='IndicatorSet') router.register(r'impactcategory', views.ImpactCategoryViewSet, basename='ImpactCategory') -router.register(r'sustainablityevaluation', views.SustainablityEvaluationViewSet, basename='SustainablityEvaluation') +router.register(r'impactindicator', views.ImpactIndicatorViewSet, basename='ImpactIndicator') +router.register(r'sustainabilityevaluation', views.SustainabilityEvaluationViewSet, basename='SustainabilityEvaluation') router.register(r'sustainabilityscore', views.SustainabilityScoreViewSet, basename='SustainabilityScore') router.register(r'circularityevaluation', views.CircularityEvaluationViewSet, basename='CircularityEvaluation') router.register(r'circularityindicator', views.CircularityIndicatorViewSet, basename='CircularityIndicator') router.register(r'circularityscore', views.CircularityScoreViewSet, basename='CircularityScore') -router.register(r'circularityenabler', views.CircularityEnablerViewSet, basename='CircularityEnabler') +router.register(r'circularityenabler', views.CircularityTrackerViewSet, basename='CircularityTracker') urlpatterns = [ diff --git a/mysite/api/urls.txt b/mysite/api/urls.txt deleted file mode 100644 index d2a2f32..0000000 --- a/mysite/api/urls.txt +++ /dev/null @@ -1,40 +0,0 @@ -router.register(r'organization', views.OrganizationViewSet, basename='Organization') -router.register(r'institution', views.InstitutionViewSet, basename='Institution') -router.register(r'company', views.CompanyViewSet, basename='Company') -router.register(r'importer', views.ImporterViewSet, basename='Importer') -router.register(r'serviceoperator', views.ServiceOperatorViewSet, basename='ServiceOperator') -router.register(r'metadata', views.MetadataViewSet, basename='Metadata') -router.register(r'instruction', views.InstructionViewSet, basename='Instruction') -router.register(r'document', views.DocumentViewSet, basename='Document') -router.register(r'material', views.MaterialViewSet, basename='Material') -router.register(r'hazardousmaterial', views.HazardousMaterialViewSet, basename='HazardousMaterial') -router.register(r'criticalrawmaterial', views.CriticalRawMaterialViewSet, basename='CriticalRawMaterial') -router.register(r'producttype', views.ProductTypeViewSet, basename='ProductType') -router.register(r'packaging', views.PackagingViewSet, basename='Packaging') -router.register(r'secondaryproduct', views.SecondaryProductViewSet, basename='SecondaryProduct') -router.register(r'emission', views.EmissionViewSet, basename='Emission') -router.register(r'composition', views.CompositionViewSet, basename='Composition') -router.register(r'product', views.ProductViewSet, basename='Product') -router.register(r'productionline', views.ProductionLineViewSet, basename='ProductionLine') -router.register(r'process', views.ProcessViewSet, basename='Process') -router.register(r'sharedprocess', views.SharedProcessViewSet, basename='SharedProcess') -router.register(r'exchange', views.ExchangeViewSet, basename='Exchange') -router.register(r'productexchange', views.ProductExchangeViewSet, basename='ProductExchange') -router.register(r'envexchange', views.EnvExchangeViewSet, basename='EnvExchange') -router.register(r'billofmaterials', views.BillOfMaterialsViewSet, basename='BillOfMaterials') -router.register(r'packaginginfo', views.PackagingInfoViewSet, basename='PackagingInfo') -router.register(r'serviceevent', views.ServiceEventViewSet, basename='ServiceEvent') -router.register(r'servicerecord', views.ServiceRecordViewSet, basename='ServiceRecord') -router.register(r'replacedcomponents', views.ReplacedComponentsViewSet, basename='ReplacedComponents') -router.register(r'modification', views.ModificationViewSet, basename='Modification') -router.register(r'correctivemaintenance', views.CorrectiveMaintenanceViewSet, basename='CorrectiveMaintenance') -router.register(r'endoflife', views.EndOfLifeViewSet, basename='EndOfLife') -router.register(r'impactcategory', views.ImpactCategoryViewSet, basename='ImpactCategory') -router.register(r'sustainablityevaluation', views.SustainablityEvaluationViewSet, basename='SustainablityEvaluation') -router.register(r'sustainabilityscore', views.SustainabilityScoreViewSet, basename='SustainabilityScore') -router.register(r'circularityevaluation', views.CircularityEvaluationViewSet, basename='CircularityEvaluation') -router.register(r'oldcircularityindicator', views.OldCircularityIndicatorViewSet, basename='OldCircularityIndicator') -router.register(r'circularityindicator', views.CircularityIndicatorViewSet, basename='CircularityIndicator') -router.register(r'circularityscore', views.CircularityScoreViewSet, basename='CircularityScore') -router.register(r'circularityenabler', views.CircularityEnablerViewSet, basename='CircularityEnabler') -router.register(r'circularitytracker', views.CircularityTrackerViewSet, basename='CircularityTracker') diff --git a/mysite/api/views.py b/mysite/api/views.py index 52b2872..7569892 100644 --- a/mysite/api/views.py +++ b/mysite/api/views.py @@ -1,8 +1,9 @@ from rest_framework import generics, status from rest_framework.response import Response from rest_framework.views import APIView -from dpp.models import ProductionLine, Process -from .serializers import ProductionLineSerializer, ProcessSerializer +from rest_framework.viewsets import ModelViewSet +from dpp.models import * +from .serializers import * # This view allows to see all Production Lines. class ProductionLinesListCreate(generics.ListCreateAPIView): @@ -40,10 +41,10 @@ def get(self, request, *args, **kwargs): serializer = ProcessSerializer(results, many=True) return Response(serializer.data, status=status.HTTP_200_OK) -from rest_framework.viewsets import ModelViewSet -from dpp.models import Institution, Company, Importer, ServiceOperator, Metadata, Document, Material, HazardousMaterial, CriticalRawMaterial, ProductType, Packaging, SecondaryProduct, Emission, Composition, Product, ProductionLine, Process, SharedProcess, Exchange, ProductExchange, EnvExchange, BillOfMaterials, PackagingInfo, ServiceEvent, ServiceRecord, ReplacedComponents, EndOfLife, ImpactCategory, SustainablityEvaluation, SustainabilityScore, CircularityEvaluation, OldCircularityIndicator, CircularityIndicator, CircularityScore, CircularityEnabler, CircularityTracker -from .serializers import InstitutionSerializer, CompanySerializer, ImporterSerializer, ServiceOperatorSerializer, MetadataSerializer, DocumentSerializer, MaterialSerializer, HazardousMaterialSerializer, CriticalRawMaterialSerializer, ProductTypeSerializer, PackagingSerializer, SecondaryProductSerializer, EmissionSerializer, CompositionSerializer, ProductSerializer, ProductionLineSerializer, ProcessSerializer, SharedProcessSerializer, ProductExchangeSerializer, EnvExchangeSerializer, BillOfMaterialsSerializer, PackagingInfoSerializer, ServiceEventSerializer, ServiceRecordSerializer, ReplacedComponentsSerializer, EndOfLifeSerializer, ImpactCategorySerializer, SustainablityEvaluationSerializer, SustainabilityScoreSerializer, CircularityEvaluationSerializer, OldCircularityIndicatorSerializer, CircularityIndicatorSerializer, CircularityScoreSerializer, CircularityEnablerSerializer +class OrganizationViewSet(ModelViewSet): + queryset = Organization.objects.all() + serializer_class = OrganizationSerializer class InstitutionViewSet(ModelViewSet): queryset = Institution.objects.all() @@ -65,6 +66,10 @@ class MetadataViewSet(ModelViewSet): queryset = Metadata.objects.all() serializer_class = MetadataSerializer +class InstructionViewSet(ModelViewSet): + queryset = Instruction.objects.all() + serializer_class = InstructionSerializer + class DocumentViewSet(ModelViewSet): queryset = Document.objects.all() serializer_class = DocumentSerializer @@ -77,17 +82,25 @@ class HazardousMaterialViewSet(ModelViewSet): queryset = HazardousMaterial.objects.all() serializer_class = HazardousMaterialSerializer -class CriticalRawMaterialViewSet(ModelViewSet): - queryset = CriticalRawMaterial.objects.all() - serializer_class = CriticalRawMaterialSerializer +class FlowViewSet(ModelViewSet): + queryset = Flow.objects.all() + serializer_class = FlowSerializer -class ProductTypeViewSet(ModelViewSet): - queryset = ProductType.objects.all() - serializer_class = ProductTypeSerializer +class ProductModelViewSet(ModelViewSet): + queryset = ProductModel.objects.all() + serializer_class = ProductModelSerializer -class PackagingViewSet(ModelViewSet): - queryset = Packaging.objects.all() - serializer_class = PackagingSerializer +class ProductBatchViewSet(ModelViewSet): + queryset = ProductBatch.objects.all() + serializer_class = ProductBatchSerializer + +class ProductPropertiesViewSet(ModelViewSet): + queryset = ProductProperties.objects.all() + serializer_class = ProductPropertiesSerializer + +class DppDetailsViewSet(ModelViewSet): + queryset = DppDetails.objects.all() + serializer_class = DppDetailsSerializer class SecondaryProductViewSet(ModelViewSet): queryset = SecondaryProduct.objects.all() @@ -101,9 +114,17 @@ class CompositionViewSet(ModelViewSet): queryset = Composition.objects.all() serializer_class = CompositionSerializer -class ProductViewSet(ModelViewSet): - queryset = Product.objects.all() - serializer_class = ProductSerializer +class ProductItemViewSet(ModelViewSet): + queryset = ProductItem.objects.all() + serializer_class = ProductItemSerializer + +class ActivityViewSet(ModelViewSet): + queryset = Activity.objects.all() + serializer_class = ActivitySerializer + +class ManufacturingProcessViewSet(ModelViewSet): + queryset = ManufacturingProcess.objects.all() + serializer_class = ManufacturingProcessSerializer class ProductionLineViewSet(ModelViewSet): queryset = ProductionLine.objects.all() @@ -113,9 +134,9 @@ class ProcessViewSet(ModelViewSet): queryset = Process.objects.all() serializer_class = ProcessSerializer -class SharedProcessViewSet(ModelViewSet): - queryset = SharedProcess.objects.all() - serializer_class = SharedProcessSerializer +class BackgroundProcessViewSet(ModelViewSet): + queryset = BackgroundProcess.objects.all() + serializer_class = BackgroundProcessSerializer class ProductExchangeViewSet(ModelViewSet): queryset = ProductExchange.objects.all() @@ -125,37 +146,49 @@ class EnvExchangeViewSet(ModelViewSet): queryset = EnvExchange.objects.all() serializer_class = EnvExchangeSerializer -class BillOfMaterialsViewSet(ModelViewSet): - queryset = BillOfMaterials.objects.all() - serializer_class = BillOfMaterialsSerializer +class AliasViewSet(ModelViewSet): + queryset = Alias.objects.all() + serializer_class = AliasSerializer + +class TransportViewSet(ModelViewSet): + queryset = Transport.objects.all() + serializer_class = TransportSerializer + +class LifeCycleEventViewSet(ModelViewSet): + queryset = LifeCycleEvent.objects.all() + serializer_class = LifeCycleEventSerializer -class PackagingInfoViewSet(ModelViewSet): - queryset = PackagingInfo.objects.all() - serializer_class = PackagingInfoSerializer +class InspectionEventViewSet(ModelViewSet): + queryset = InspectionEvent.objects.all() + serializer_class = InspectionEventSerializer -class ServiceEventViewSet(ModelViewSet): - queryset = ServiceEvent.objects.all() - serializer_class = ServiceEventSerializer +class MaintenanceEventViewSet(ModelViewSet): + queryset = MaintenanceEvent.objects.all() + serializer_class = MaintenanceEventSerializer -class ServiceRecordViewSet(ModelViewSet): - queryset = ServiceRecord.objects.all() - serializer_class = ServiceRecordSerializer +class ItemExchangeViewSet(ModelViewSet): + queryset = ItemExchange.objects.all() + serializer_class = ItemExchangeSerializer -class ReplacedComponentsViewSet(ModelViewSet): - queryset = ReplacedComponents.objects.all() - serializer_class = ReplacedComponentsSerializer +class DisassemblyEventViewSet(ModelViewSet): + queryset = DisassemblyEvent.objects.all() + serializer_class = DisassemblyEventSerializer -class EndOfLifeViewSet(ModelViewSet): - queryset = EndOfLife.objects.all() - serializer_class = EndOfLifeSerializer +class IndicatorSetViewSet(ModelViewSet): + queryset = IndicatorSet.objects.all() + serializer_class = IndicatorSetSerializer class ImpactCategoryViewSet(ModelViewSet): queryset = ImpactCategory.objects.all() serializer_class = ImpactCategorySerializer -class SustainablityEvaluationViewSet(ModelViewSet): - queryset = SustainablityEvaluation.objects.all() - serializer_class = SustainablityEvaluationSerializer +class ImpactIndicatorViewSet(ModelViewSet): + queryset = ImpactIndicator.objects.all() + serializer_class = ImpactIndicatorSerializer + +class SustainabilityEvaluationViewSet(ModelViewSet): + queryset = SustainabilityEvaluation.objects.all() + serializer_class = SustainabilityEvaluationSerializer class SustainabilityScoreViewSet(ModelViewSet): queryset = SustainabilityScore.objects.all() @@ -165,10 +198,6 @@ class CircularityEvaluationViewSet(ModelViewSet): queryset = CircularityEvaluation.objects.all() serializer_class = CircularityEvaluationSerializer -class OldCircularityIndicatorViewSet(ModelViewSet): - queryset = OldCircularityIndicator.objects.all() - serializer_class = OldCircularityIndicatorSerializer - class CircularityIndicatorViewSet(ModelViewSet): queryset = CircularityIndicator.objects.all() serializer_class = CircularityIndicatorSerializer @@ -177,6 +206,6 @@ class CircularityScoreViewSet(ModelViewSet): queryset = CircularityScore.objects.all() serializer_class = CircularityScoreSerializer -class CircularityEnablerViewSet(ModelViewSet): - queryset = CircularityEnabler.objects.all() - serializer_class = CircularityEnablerSerializer +class CircularityTrackerViewSet(ModelViewSet): + queryset = CircularityTracker.objects.all() + serializer_class = CircularityTrackerSerializer diff --git a/mysite/api/views.txt b/mysite/api/views.txt deleted file mode 100644 index 7dfed30..0000000 --- a/mysite/api/views.txt +++ /dev/null @@ -1,164 +0,0 @@ -from rest_framework.viewsets import ModelViewSet -from .models import Organization, Institution, Company, Importer, ServiceOperator, Metadata, Instruction, Document, Material, HazardousMaterial, CriticalRawMaterial, ProductType, Packaging, SecondaryProduct, Emission, Composition, Product, ProductionLine, Process, SharedProcess, Exchange, ProductExchange, EnvExchange, BillOfMaterials, PackagingInfo, ServiceEvent, ServiceRecord, ReplacedComponents, Modification, CorrectiveMaintenance, EndOfLife, ImpactCategory, SustainablityEvaluation, SustainabilityScore, CircularityEvaluation, OldCircularityIndicator, CircularityIndicator, CircularityScore, CircularityEnabler, CircularityTracker -from .serializers import OrganizationSerializer, InstitutionSerializer, CompanySerializer, ImporterSerializer, ServiceOperatorSerializer, MetadataSerializer, InstructionSerializer, DocumentSerializer, MaterialSerializer, HazardousMaterialSerializer, CriticalRawMaterialSerializer, ProductTypeSerializer, PackagingSerializer, SecondaryProductSerializer, EmissionSerializer, CompositionSerializer, ProductSerializer, ProductionLineSerializer, ProcessSerializer, SharedProcessSerializer, ExchangeSerializer, ProductExchangeSerializer, EnvExchangeSerializer, BillOfMaterialsSerializer, PackagingInfoSerializer, ServiceEventSerializer, ServiceRecordSerializer, ReplacedComponentsSerializer, ModificationSerializer, CorrectiveMaintenanceSerializer, EndOfLifeSerializer, ImpactCategorySerializer, SustainablityEvaluationSerializer, SustainabilityScoreSerializer, CircularityEvaluationSerializer, OldCircularityIndicatorSerializer, CircularityIndicatorSerializer, CircularityScoreSerializer, CircularityEnablerSerializer, CircularityTracker - -class OrganizationViewSet(ModelViewSet): - queryset = Organization.objects.all() - serializer_class = OrganizationSerializer - -class InstitutionViewSet(ModelViewSet): - queryset = Institution.objects.all() - serializer_class = InstitutionSerializer - -class CompanyViewSet(ModelViewSet): - queryset = Company.objects.all() - serializer_class = CompanySerializer - -class ImporterViewSet(ModelViewSet): - queryset = Importer.objects.all() - serializer_class = ImporterSerializer - -class ServiceOperatorViewSet(ModelViewSet): - queryset = ServiceOperator.objects.all() - serializer_class = ServiceOperatorSerializer - -class MetadataViewSet(ModelViewSet): - queryset = Metadata.objects.all() - serializer_class = MetadataSerializer - -class InstructionViewSet(ModelViewSet): - queryset = Instruction.objects.all() - serializer_class = InstructionSerializer - -class DocumentViewSet(ModelViewSet): - queryset = Document.objects.all() - serializer_class = DocumentSerializer - -class MaterialViewSet(ModelViewSet): - queryset = Material.objects.all() - serializer_class = MaterialSerializer - -class HazardousMaterialViewSet(ModelViewSet): - queryset = HazardousMaterial.objects.all() - serializer_class = HazardousMaterialSerializer - -class CriticalRawMaterialViewSet(ModelViewSet): - queryset = CriticalRawMaterial.objects.all() - serializer_class = CriticalRawMaterialSerializer - -class ProductTypeViewSet(ModelViewSet): - queryset = ProductType.objects.all() - serializer_class = ProductTypeSerializer - -class PackagingViewSet(ModelViewSet): - queryset = Packaging.objects.all() - serializer_class = PackagingSerializer - -class SecondaryProductViewSet(ModelViewSet): - queryset = SecondaryProduct.objects.all() - serializer_class = SecondaryProductSerializer - -class EmissionViewSet(ModelViewSet): - queryset = Emission.objects.all() - serializer_class = EmissionSerializer - -class CompositionViewSet(ModelViewSet): - queryset = Composition.objects.all() - serializer_class = CompositionSerializer - -class ProductViewSet(ModelViewSet): - queryset = Product.objects.all() - serializer_class = ProductSerializer - -class ProductionLineViewSet(ModelViewSet): - queryset = ProductionLine.objects.all() - serializer_class = ProductionLineSerializer - -class ProcessViewSet(ModelViewSet): - queryset = Process.objects.all() - serializer_class = ProcessSerializer - -class SharedProcessViewSet(ModelViewSet): - queryset = SharedProcess.objects.all() - serializer_class = SharedProcessSerializer - -class ExchangeViewSet(ModelViewSet): - queryset = Exchange.objects.all() - serializer_class = ExchangeSerializer - -class ProductExchangeViewSet(ModelViewSet): - queryset = ProductExchange.objects.all() - serializer_class = ProductExchangeSerializer - -class EnvExchangeViewSet(ModelViewSet): - queryset = EnvExchange.objects.all() - serializer_class = EnvExchangeSerializer - -class BillOfMaterialsViewSet(ModelViewSet): - queryset = BillOfMaterials.objects.all() - serializer_class = BillOfMaterialsSerializer - -class PackagingInfoViewSet(ModelViewSet): - queryset = PackagingInfo.objects.all() - serializer_class = PackagingInfoSerializer - -class ServiceEventViewSet(ModelViewSet): - queryset = ServiceEvent.objects.all() - serializer_class = ServiceEventSerializer - -class ServiceRecordViewSet(ModelViewSet): - queryset = ServiceRecord.objects.all() - serializer_class = ServiceRecordSerializer - -class ReplacedComponentsViewSet(ModelViewSet): - queryset = ReplacedComponents.objects.all() - serializer_class = ReplacedComponentsSerializer - -class ModificationViewSet(ModelViewSet): - queryset = Modification.objects.all() - serializer_class = ModificationSerializer - -class CorrectiveMaintenanceViewSet(ModelViewSet): - queryset = CorrectiveMaintenance.objects.all() - serializer_class = CorrectiveMaintenanceSerializer - -class EndOfLifeViewSet(ModelViewSet): - queryset = EndOfLife.objects.all() - serializer_class = EndOfLifeSerializer - -class ImpactCategoryViewSet(ModelViewSet): - queryset = ImpactCategory.objects.all() - serializer_class = ImpactCategorySerializer - -class SustainablityEvaluationViewSet(ModelViewSet): - queryset = SustainablityEvaluation.objects.all() - serializer_class = SustainablityEvaluationSerializer - -class SustainabilityScoreViewSet(ModelViewSet): - queryset = SustainabilityScore.objects.all() - serializer_class = SustainabilityScoreSerializer - -class CircularityEvaluationViewSet(ModelViewSet): - queryset = CircularityEvaluation.objects.all() - serializer_class = CircularityEvaluationSerializer - -class OldCircularityIndicatorViewSet(ModelViewSet): - queryset = OldCircularityIndicator.objects.all() - serializer_class = OldCircularityIndicatorSerializer - -class CircularityIndicatorViewSet(ModelViewSet): - queryset = CircularityIndicator.objects.all() - serializer_class = CircularityIndicatorSerializer - -class CircularityScoreViewSet(ModelViewSet): - queryset = CircularityScore.objects.all() - serializer_class = CircularityScoreSerializer - -class CircularityEnablerViewSet(ModelViewSet): - queryset = CircularityEnabler.objects.all() - serializer_class = CircularityEnablerSerializer - -class CircularityTrackerViewSet(ModelViewSet): - queryset = CircularityTracker.objects.all() - serializer_class = CircularityTrackerSerializer - diff --git a/mysite/count_attributes.py b/mysite/count_attributes.py new file mode 100644 index 0000000..289709a --- /dev/null +++ b/mysite/count_attributes.py @@ -0,0 +1,228 @@ +import pandas as pd +import os +import csv +from django import setup as setup_django +from django.apps import apps + +# Set up Django environment +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings') +setup_django() + +DATA_PATH = '../../Data/' +DPP_PATH = DATA_PATH + 'DPP_Structure.xlsx' + +def analyze_models(): + results = [] + for model in apps.get_models(): + model_name = model.__name__ + fields = model._meta.local_fields + for parent in model.__bases__: + if parent.__name__ != 'Model': + fields += parent._meta.local_fields + optional = 0 + required = 0 + + for field in fields: + if field.primary_key: + required += 1 + elif field.blank or field.null: + optional += 1 + else: + required += 1 + + results.append({ + 'Model': model_name, 'Optional': optional, 'Required': required + }) + + return results + +def write_csv(results, filename=DATA_PATH + 'model_attributes.csv'): + with open(filename, mode='w', newline='', encoding='utf-8') as file: + writer = csv.DictWriter( + file, fieldnames=['Model', 'Optional', 'Required'] + ) + writer.writeheader() + writer.writerows(results) + print(f"Results written to {filename}") + +def read_excel_models(filepath='DPP_Structure.xlsx', columns=[]): + use_sheets = [ + 'Metadata', 'ProductInformation', 'DesignAndMaterials', + 'ManufacturingInformation', 'Sustainability', 'Circularity', + 'SustainabilityIndicators', 'CircularityIndicators', # No attributes here + 'ServiceEvents', 'Operators', + ] + + structure = pd.read_excel(filepath, sheet_name=None, dtype='str') + data = [] + columns = columns or [ + 'Class', 'Attribute', 'dataType', 'dataSchema', + 'ESPR[Optional|Mandatory]', 'Data Source', 'MCDA Applicable', + 'Scale Type', 'Application Context', 'Output layer', + ] + + for sheet in structure: + if sheet not in use_sheets: + continue + df = structure[sheet] + df.columns = ['Class', 'Attribute'] + list(df.columns[2:]) + df = df[df.columns.intersection(columns)].copy() + # Select first part of MCDA columns + if 'MCDA Applicable' in df.columns: + df['MCDA Applicable'] = df['MCDA Applicable'].str[0] + if 'Scale Type' in df.columns: + df['MCDA type'] = df['Scale Type'].str.split('[:, ]', n=1).str[0] + if 'Application Context' in df.columns: + df['Application Context'] = df['Application Context'].str.lower() + # Drop if Attribute is empty + df = df.loc[df.Attribute.notna()] + + df['Sheet'] = sheet + data.append(df) + + return pd.concat(data) + +def attribute_overview(): + """Count the number of attributes of all Django models. + Returns the result (as DataFrame), and prints it as CSV. + """ + groups = {'Activity': 'Manufacturing', 'Alias': 'Administrative', 'BackgroundProcess': 'Manufacturing', 'CircularityEvaluation': 'Sustainability', 'CircularityIndicator': 'Sustainability', 'CircularityScore': 'Sustainability', 'CircularityTracker': 'Sustainability', 'Company': 'Administrative', 'Component': 'Material', 'Composition': 'Material', 'Concentration': 'Material', 'DisassemblyEvent': 'Service events', 'Document': 'Administrative', 'Document.type': 'Administrative', 'DppDetails': 'Product', 'Emission': 'Manufacturing', 'EnvExchange': 'Manufacturing', 'Exchange': 'Manufacturing', 'Facility': 'Manufacturing', 'Flow': 'Product', 'HazardousMaterial': 'Material', 'ImpactCategory': 'Sustainability', 'ImpactIndicator': 'Sustainability', 'Importer': 'Product', 'IndicatorSet': 'Sustainability', 'InspectionEvent': 'Service events', 'Institution': 'Sustainability', 'Instruction': 'Administrative', 'ItemExchange': 'Service events', 'LifeCycleEvent': 'Service events', 'MaintenanceEvent': 'Service events', 'ManufacturingProcess': 'Manufacturing', 'Material': 'Material', 'Metadata': 'Administrative', 'Organization': 'Administrative', 'Process': 'Manufacturing', 'ProductBatch': 'Product', 'ProductExchange': 'Manufacturing', 'ProductItem': 'Product', 'ProductModel': 'Product', 'ProductProperties': 'Product', 'ProductionLine': 'Manufacturing', 'Publisher': 'Administrative', 'SecondaryProduct': 'Product', 'ServiceOperator': 'Service events', 'SustainabilityEvaluation': 'Sustainability', 'SustainabilityScore': 'Sustainability', 'Transport': 'Manufacturing'} + rows = [] + for model in apps.get_models(): + model_name = model.__name__ + if model_name in ['User', 'Group', 'Session', 'Permission', 'LogEntry', 'ContentType', 'Alias']: + continue + # Add inherited fields (assuming a model has only 1 parent) + fields = [] + while model.__name__ != 'Model': + if not model._meta.abstract: + fields += model._meta.local_fields + model = model.__bases__[0] + + for field in fields: + required = True + if not field.primary_key and (field.blank or field.null): + required = False + rows.append([model_name, field.name, required]) + + att_df = pd.DataFrame(columns=['Class', 'Attribute', 'Required'], data=rows) + # Read excel data and copy inherited rows to child classes + excel_data = read_excel_models(DPP_PATH) + # Replace 2 class names + excel_data['Class'] = excel_data['Class'].replace('ManufacturingInformation', 'ProductionLine') + excel_data['dataSchema'] = excel_data['dataSchema'].replace('ManufacturingInformation', 'ProductionLine') + parent_mask = excel_data.Attribute=='parent' + # inherit_list = [excel_data] + for i, row in excel_data[parent_mask][::-1].iterrows(): + inherit = excel_data[excel_data.Class==row.dataSchema].copy() + inherit['Class'] = row.Class + # inherit_list.append(inherit) + excel_data = pd.concat([excel_data, inherit]) + parent_mask = excel_data.Attribute=='parent' + excel_data.loc[parent_mask, 'Attribute'] = ( + excel_data.loc[parent_mask, 'dataSchema'].str.lower() + '_ptr' + ) + + att_df = att_df.merge(excel_data, 'outer', sort=True) + # Fill info for ptr and id fields + ptr_mask = att_df.Attribute.str.endswith('_ptr') + id_mask = (att_df.Attribute == 'id') & att_df.dataType.isna() + fill_cols = ['dataType', 'Data Source', 'MCDA Applicable', 'Output layer'] + att_df.loc[ptr_mask, fill_cols] = ['parent', 'Auto-created', 'N', '-'] + att_df.loc[id_mask, fill_cols] = ['int', 'Auto-created', 'N', '-'] + + # Add group names + att_df['Group'] = att_df['Class'].map(groups) + + att_df.to_csv(DATA_PATH + 'DPP_Structure.csv', index=False) + + return att_df + + +def analyze_excel(filepath='DPP_Structure.xlsx'): + use_sheets = [ + 'Metadata', 'ProductInformation', 'DesignAndMaterials', + 'ManufacturingInformation', 'Sustainability', 'Circularity', + # 'SustainabilityIndicators', 'CircularityIndicators', # No attributes here + 'ServiceEvents', 'Operators', + ] + + xls = pd.ExcelFile(filepath) + all_sheets = xls.sheet_names + mandatory_df = pd.DataFrame() + classes = [] + outputs = [] + + for sheet in all_sheets: + if sheet not in use_sheets: + continue + df = pd.read_excel(filepath, sheet_name=sheet, dtype='str') + df.columns = ['Class', 'Attribute'] + list(df.columns[2:]) + + # Drop rows that shouldn't be counted + df = df.loc[df.Attribute.notna() & (df.Attribute!='parent')] + df = df.loc[df['Output layer'].notna()] + + # Select MCDA datapoints, also count Country links + datapoints = df.loc[df['Output layer']!='-'].copy() + datapoints.loc[datapoints.dataSchema=='Country', 'dataType'] = 'str' + datapoints = datapoints.loc[~datapoints['dataType'].isin(['category', 'object'])] + + df = df.loc[~df['dataType'].isin(['category', 'option'])] + + # Count attributes by class and data source + #TODO: assume multipliers for each Class, to calculate total fieds + classes.append(df.pivot_table('Attribute', 'Class', 'Data Source', 'count')) + # classes.append(df.groupby('Class')[['Attribute']].count()) + # df.pivot_table('Attribute', 'Class', ['Data Source', 'Output layer'], 'count') + + outputs.append(df.pivot_table('Attribute', 'Class', 'Output layer', 'count')) + + # Group by mandatory status and count + mandatory = datapoints.groupby('ESPR[Optional|Mandatory]')[['Class']].count() + mandatory_df[sheet] = mandatory['Class'] + + class_df = pd.concat(classes) + output_df = pd.concat(outputs) + + for name, data in [('mandatory', mandatory_df), ('classes', class_df), ('outputs', output_df)]: + data.fillna(0).to_csv(f"{name}_count.csv") + +def expand_hierarchy(): + columns = ['Class', 'Attribute', 'MCDA Applicable', 'Scale Type', 'ESPR[Optional|Mandatory]', 'Performance Type', 'MCDA Role'] #, 'Output layer', 'Data Source'] + structure = read_excel_models(DPP_PATH, columns) + + # structure['Application Context'] = structure['Application Context'].str.strip(' ?') + structure = structure[structure['MCDA Applicable'] == 'Y'].copy() + structure['Attributes'] = 'All' + + mcda_file = DATA_PATH + "MCDA_hierarchy.xlsx" + mcda_df = pd.read_excel(mcda_file) + joined = mcda_df.merge(structure[['Class', 'Attribute', 'Attributes']], 'left') + joined['Attribute'] = joined['Attribute'].fillna(joined['Attributes']) + joined = joined[joined.Attribute.notna() & (joined.Attribute != 'All')] + joined = joined.drop(columns=['Attributes']) + joined['Attribute'] = joined['Attribute'].str.split(r',\s*') + joined = joined.explode('Attribute') + + joined = joined.merge(structure, 'left') + + circ_ind = mcda_df[mcda_df['Class'] == "CircularityIndicator"].copy() + circ_ind['Class'] = circ_ind['Attributes'] + circ_ind = circ_ind.loc[:, :'Class'].merge(structure, 'left') + joined = pd.concat([joined, circ_ind], ignore_index=True) + + joined = joined.drop(columns=['MCDA Applicable', 'Attributes']) + joined.drop_duplicates(inplace=True) + #for col in ['Application Context', 'MCDA type', 'Scale Type', 'Sheet']: + # joined[col] = joined[col].ffill() + + print("Columns exported:", list(joined.columns)) + joined.to_csv(DATA_PATH + 'joined.csv', index=False) + +if __name__ == '__main__': + # results = analyze_models() + # write_csv(results) + result = attribute_overview() + # analyze_excel(DPP_PATH) + #expand_hierarchy() diff --git a/mysite/dpp/admin.py b/mysite/dpp/admin.py index 3b597e0..9186a49 100644 --- a/mysite/dpp/admin.py +++ b/mysite/dpp/admin.py @@ -1,11 +1,33 @@ from django.contrib import admin -from .models import Institution, Company, Importer, ServiceOperator, Metadata, Instruction, Document, Material, HazardousMaterial, CriticalRawMaterial, ProductType, Packaging, SecondaryProduct, Emission, Composition, Product, ProductionLine, Process, SharedProcess, ProductExchange, EnvExchange, BillOfMaterials, PackagingInfo, ServiceEvent, ServiceRecord, ReplacedComponents, EndOfLife, ImpactCategory, SustainablityEvaluation, SustainabilityScore, CircularityEvaluation, OldCircularityIndicator, CircularityIndicator, CircularityScore, CircularityEnabler, CircularityTracker +from .models import * # Models that can be modified by admin: admin.site.register(Company) admin.site.register(Importer) +admin.site.register(Facility) +admin.site.register(Activity) +admin.site.register(ManufacturingProcess) +admin.site.register(BackgroundProcess) admin.site.register(Process) admin.site.register(ProductionLine) -admin.site.register(ProductType) +admin.site.register(Flow) +admin.site.register(ProductModel) +admin.site.register(Composition) +admin.site.register(DppDetails) admin.site.register(ProductExchange) +admin.site.register(EnvExchange) +admin.site.register(Instruction) +admin.site.register(Emission) +admin.site.register(ImpactCategory) +admin.site.register(CircularityIndicator) +@admin.register(Document) +class DocumentAdmin(admin.ModelAdmin): + list_display = ['filename', 'get_instructions'] + filter_horizontal = ('instructions',) + list_filter = ['instructions'] + search_fields = ['file'] + + def get_instructions(self, obj): + return ", ".join(label.name for label in obj.instructions.all()) + get_instructions.short_description = 'Instructions' \ No newline at end of file diff --git a/mysite/dpp/data_imports.py b/mysite/dpp/data_imports.py new file mode 100644 index 0000000..8f863f0 --- /dev/null +++ b/mysite/dpp/data_imports.py @@ -0,0 +1,136 @@ +from pathlib import Path +import pandas as pd +# from django.contrib.auth.models import User +from dpp.models import CircularityIndicator, Instruction, ImpactIndicator, ImpactCategory +"""NOTE: to run this, use a command, I think manage.py runscript data_imports""" + +def csv_to_django(file_path: Path | str, Model, relations={}): + """ + Read data from a CSV file into a Django model. + """ + + # Convert DataFrame to list of model instances + file_path = Path(file_path) + df = pd.read_csv(file_path) + fields = set([field.name for field in Model._meta.get_fields()]) + ignored = set(df.columns) - fields + df = df.drop(columns=ignored) + if any(ignored): + print(f"Columns ignored because they can't be linked: {ignored}") + for col, val in relations.items(): + df[col + '_id'] = val + + for _, row in df.iterrows(): + # new_object = Model(**row.to_dict()) + # new_object.save() + Model(**row.to_dict()).save() + + print(f"{file_path.name} has been loaded as {Model.__name__} into the Django database.") + + +def __main__(): + circularity_csv = 'init_data/circularity_indicators.csv' + csv_to_django(circularity_csv, CircularityIndicator) + label_csv = 'init_data/document_labels.csv' + csv_to_django(label_csv, Instruction) + socioecon, _ = ImpactCategory.objects.get_or_create(name="Socio-economic impact") + socioecon_csv = 'init_data/socioecon_indicators.csv' + csv_to_django(socioecon_csv, ImpactIndicator, relations={'impact_category': socioecon.pk}) + +""" +R_CHOICES = { + 'R0 - Refuse': [ + ('hazardous', 'Hazardous substances'), + ('fossil', 'Fossil energy use'), + ('nonrenewable', 'Non-renewable materials'), + ('other', 'Other materials'), + ('consumption', 'Avoided product consumption'), + ], + 'R1 - Rethink': [ + ('modularity', 'Modularity'), + ('product_takeback', 'Product take-back'), # Appears multiple times + ('crm', 'Critical Materials'), + ('shared_use', 'Shared use'), + ('durability', 'Durability'), + ('potential_use_during_lifetime', 'Potential use during lifetime'), + ('multifunctionality', 'Multifunctionality'), + ('modularity_score', 'Modularity score'), + ('materials', 'Materials'), + ('number_of_components', 'Number of components'), + ('material_composition_complexity', 'Material composition complexity'), + ('tools_required', 'Number of tools required'), + ('separable_pieces_ratio', 'Separable pieces ratio'), + ], + 'R2 - Reduce': [ + ('reduce_raw_materials_intensity', 'Raw materials intensity reduction'), + ('reduce_energy_intensity', 'Energy intensity reduction'), + ('reduce_energy_consumption', 'Energy consumption reduction'), + ('reduce_waste_generation', 'Waste generation reduction'), + ('reduce_material_losses', 'Material losses reduction'), + ('reduce_water_intensity', 'Water intensity reduction'), + ('reduce_water_consumption', 'Water consumption'), + ], + 'R3 - Reuse': [ + ('reuse_rate', 'Reuse rate'), + ('product_takeback', 'Product take-back'), + ('consumer_awareness', 'Consumer awareness'), + ('potential_use', 'Potential use'), + ('ownership_time', 'Ownership time'), + ('voidance_of_reuse_barriers', 'Voidance of reuse rarriers'), + ('reuse_potential', 'Reuse potential'), + ('costs_of_reuse', 'Costs of reuse'), + ('access_to_parts', 'Access to high-value parts'), + ], + 'R4 - Repair': [ + ('longevity_extension', 'Longevity extension'), + ('extension_of_producer_responsibility', 'Extension of producer responsibility'), + ('consumer_awareness', 'Consumer awareness'), + ('potential_repair', 'Potential repair'), + ('repairability_score', 'Repairability score'), + ('durability_score', 'Durability score'), + ('non_destructive_disassembly_score', 'Non-destructive disassembly score'), + ('ease_of_reassembly', 'Ease of reassembly'), + ], + 'R5 - Refurbish': [ + ('product_takeback', 'Product take-back'), + ('refurbished_content', 'Refurbished content'), + ('refurbishment_potential', 'Refurbishment rotential'), + ('refurbishment_score', 'Refurbishment score'), + ('upgradability_score', 'Upgradability score'), + ], + 'R6 - Remanufacture': [ + ('product_takeback', 'Product take-back'), + ('remanufacturing_effectiveness', 'Remanufacturing effectiveness'), + ('consumer_awareness', 'Consumer awareness'), + ('remanufacturing_content', 'Remanufacturing content'), + ('remanufacturing_score', 'Remanufacturing score'), + ], + 'R7 - Repurpose': [ + ('secondary_raw_materials', 'Secondary raw materials'), + ('hazardous_waste_diverted', 'Hazardous waste diverted from disposal'), + ('nonhazardous_waste_diverted', 'Non-hazardous waste diverted from disposal'), + ], + 'R8 - Recycle': [ + ('overall_recycling_rates', 'Overall recycling rates'), + ('recycling_rate_for_waste_streams', 'Recycling rate for waste streams'), + ('waste_generation', 'Waste generation'), + ('reverse_logistics', 'Reverse logistics'), + ('recycling_potential', 'Recycling potential'), + ('design_for_recyclability', 'Design for recyclability'), + ('recycling_compatibility_score', 'Recycling compatibility score'), + ('material_homogeneity_score', 'Material homogeneity score'), + ('hazardous_substance_barrier', 'Hazardous substance barrier'), + ('high_purity_sorting_possible', 'High purity sorting possible'), + ('use_of_recyclable_materials', 'Use of easily recyclable materials'), + ('recycling_collection_rate', 'Recycling collection rate'), + ], + 'R9 - Recover': [ + ('waste_diversion_from_landfill', 'Waste diversion from landfill'), + ('potential_recovery', 'Potential recovery'), + ('hazardous_waste_disposal', 'Hazardous waste directed to disposal'), + ('nonhazardous_waste_disposal', 'Non-hazardous waste directed to disposal'), + ('energy_recoverability_benefit', 'Energy recoverability benefit'), + ('raw_materials_input', 'Raw materials input'), + ], +} +""" \ No newline at end of file diff --git a/mysite/dpp/forms.py b/mysite/dpp/forms.py new file mode 100644 index 0000000..adacc51 --- /dev/null +++ b/mysite/dpp/forms.py @@ -0,0 +1,38 @@ +from django import forms +from django.urls import reverse, reverse_lazy +from django.contrib.admin.widgets import RelatedFieldWidgetWrapper +from django.contrib import admin + +class CustomRFWidget(RelatedFieldWidgetWrapper): + def get_related_url(self, info, action, *args): + return reverse_lazy("%s:%s_%s" % (info + (action,))) + +def get_model_form_plus(thismodel, used_fields): + + class FormWithAutoAdd(forms.ModelForm): + """ + A ModelForm mixin that automatically adds a "+" (add another) button + next to every ForeignKey field, fully compatible with Crispy forms + """ + class Meta: + model = thismodel + fields = used_fields + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + for field_name, field in self.fields.items(): + # Detect ForeignKey fields (ModelChoiceField with queryset) + if isinstance(field, forms.ModelChoiceField) and field.queryset.model: + # Get the default widget for the M2M field + self.fields[field_name].widget = CustomRFWidget( + widget=self.fields[field_name].widget, + rel=thismodel._meta.get_field(field_name).remote_field, + admin_site=admin.site, # reuse admin URLs and permissions + can_add_related=True, + can_change_related=False, # add edit pencil (only works if action change -> update) + can_delete_related=False, + can_view_related=False, + ) + + return FormWithAutoAdd \ No newline at end of file diff --git a/mysite/dpp/lca.py b/mysite/dpp/lca.py new file mode 100644 index 0000000..b036a99 --- /dev/null +++ b/mysite/dpp/lca.py @@ -0,0 +1,377 @@ +"""Basic outline and functions for doing LCA calculations, +and to import and export DPP data to Brightway. +""" +import bw2data as bwd +import bw2io as bwi +import datetime +from .models import * + +RESOURCE_UNITS = [ + 'kg', 'g', 'lb', 'oz', 'l', 'cm3', 'dm3', 'm3', 'ft3', 'gal', + 'liters', 'cubic meters', 'cubic feet', 'gallons', + 'kWh', 'MWh', 'MJ', 'GJ', +] +# for cat in ['Mass', 'Volume', 'Energy']: +# RESOURCE_UNITS += list(UNIT_CHOICES[cat].keys()) + list(UNIT_CHOICES[cat].values()) + +# Methods that are known to have zero calculated impact +EXCLUDED_METHODS = { + ("EF v3.1", "climate change: land use and land use change", "global warming potential (GWP100)"), + ("EF v3.1", "ionising radiation: human health", "human exposure efficiency relative to u235"), + ("EF v3.1", "ozone depletion", "ozone depletion potential (ODP)"), + ("EF v3.1", "water use", "user deprivation potential (deprivation-weighted water consumption)"), +} +DEFAULT_REMOTE_PROJECT = "ecoinvent-3.12-biosphere" + +def setup_project(project_name: str) -> None: + """Initialize the project if needed, and check that it is complete.""" + if project_name not in bwd.projects: + bwi.remote.install_project(DEFAULT_REMOTE_PROJECT, project_name) + bwd.projects.set_current(project_name) + +def ensure_methods(family: str): + """ + Make sure that the LCIA family and all its methods exist + in the DPP database. + Returns: IndicatorSet + """ + # Import here to avoid circular imports + from .models import IndicatorSet, ImpactIndicator, ImpactCategory + + try: + method_set = IndicatorSet.objects.get(name=family) + except IndicatorSet.DoesNotExist: + method_set = IndicatorSet.objects.create(name=family, start_date=datetime.date.today()) + methods = [ + m for m in bwd.methods + if m[0] == family and m not in EXCLUDED_METHODS + ] + if len(methods) < len(ImpactIndicator.objects.filter(indicator_set=method_set)): + return method_set + unknown_category, _ = ImpactCategory.objects.get_or_create(name='Unknown') + for m in methods: + ImpactIndicator.objects.update_or_create( + method=m[1], + unit=bwd.methods[m].get('unit'), + indicator_set=method_set, + impact_category=unknown_category, + is_environmental=True, + ) + return method_set + +def prompt_choice(title: str, options: list, default_index: int = 0): + """Simple numbered menu. CC-BY EMPA""" + yellow = "\033[1;33m" + green = "\033[1;32m" + reset = "\033[0m" + print(f"\n{yellow}{title}{reset}") + for idx, opt in enumerate(options, 1): + default_marker = " (default)" if idx - 1 == default_index else "" + print(f"{green} {idx}) {opt}{default_marker}{reset}") + ans = input(f"{yellow}Select option:{reset} ").strip() + if not ans: + return options[default_index] + try: + idx = int(ans) - 1 + if 0 <= idx < len(options): + return options[idx] + except Exception: + pass + print("Invalid choice, using default.") + return options[default_index] + +def get_or_create_bw_process(dpp_product): + raise NotImplementedError() + return + +def biosphere_to_dpp_flows(): + """Convert all biosphere flows to Emission objects""" + from .models import Emission + unit_map = { + 'cubic meter': 'm3', + 'cubic meter-year': 'm3-yr', + 'kilo Becquerel': 'kBq', + 'kilogram': 'kg', + 'megajoule': 'MJ', + 'square meter': 'm2', + 'square meter-year': 'm2-yr', + 'standard cubic meter': 'Nm3', + } + setup_project("L4M-DPP") + biosphere_db = bwd.Database(DEFAULT_REMOTE_PROJECT) + bio_flows = set((i['name'], i['unit']) for i in list(biosphere_db)) + if len(bio_flows) <= len(Emission.objects.all()): return + for name, unit in bio_flows: + if unit in unit_map: + unit = unit_map[unit] + Emission.objects.update_or_create(name=name, unit=unit) + +def find_biosphere_flow(exc, biosphere_db): + compartment_map = { + 'air-urban': ('air', 'urban air close to ground'), + 'air-rural': ('air', 'non-urban air or from high stacks'), + 'air-lt': ('air', 'low population density, long-term'), + 'air-indoor': ('air', 'indoor'), # Doesn't exist + 'air-strato': ('air', 'lower stratosphere + upper troposphere'), + 'air': ('air',), + 'uptake': ('direct human uptake',), # Doesn't exist + 'soil-agri': ('soil', 'agricultural'), + 'soil-forest': ('soil', 'forestry'), + 'soil-indu': ('soil', 'industrial'), + 'soil': ('soil',), + 'surface_water': ('water', 'surface water'), + 'seawater': ('water', 'ocean'), + 'groundwater': ('water', 'ground-'), + 'groundwater-lt': ('water', 'ground-, long-term'), + 'groundwater-deep': ('water', 'fossil well'), + 'water': ('water',), + } + name = exc.substance.name.lower() + categories = compartment_map[exc.compartment] + if exc.direction == "in": + if 'soil' in categories: + categories = ('natural resource', 'in ground') + elif 'air' in categories: + categories = ('natural resource', 'in air') + elif 'water' in categories: + if 'fossil well' in categories: + categories = ('natural resource', 'fossil well') + else: + categories = ('natural resource', 'in water') + else: + categories = ('natural resource', 'biotic') + + for act in biosphere_db: + if act["name"].lower() == name and categories == act.get("categories"): + return (act['database'], act['code']) + + # If exact match fails: search name + act = biosphere_db.search(name)[0] + return (act['database'], act['code']) + + +def convert_dpp_to_brightway(processes: list, db_name: str): + """ + Convert DPP processes to Brightway activities in db_name + + :param dpp_process: List of ManufacturingProcess + :param db_name: Bightway database name, to add the activity to. + """ + # Import here to avoid circular imports + from .models import ProductExchange, EnvExchange + + biosphere = bwd.Database(DEFAULT_REMOTE_PROJECT) + bw_activities = {} + for dpp_process in processes: + location = str(dpp_process.facility.country) if dpp_process.facility else 'GLO' + exchanges = [{ + "input": (db_name, dpp_process.pk), + "amount": dpp_process.amount, + "type": "production", # Reference flow + "unit": dpp_process.functional_flow.model.unit, + }] + if exchanges[0]['unit'] in RESOURCE_UNITS: + stage = 'Raw material acquisition' + else: + stage = 'Manufacturing' + for exc in ProductExchange.objects.filter(process=dpp_process): + if exc.product.manufacturing_info not in processes: + continue # Cutoff in case max_depth was used. + sign = 1 if exc.direction == 'in' else -1 + try: + source_db = exc.product.manufacturing_info.database + except AttributeError: + source_db = db_name + exchanges.append({ + "input": (source_db, exc.product.manufacturing_info.pk), + "amount": sign * exc.amount, + "type": "technosphere", + "unit": exc.product.model.unit, + }) + for exc in EnvExchange.objects.filter(process=dpp_process): + bioshpere_flow = find_biosphere_flow(exc, biosphere) + exchanges.append({ + "input": bioshpere_flow, + "amount": exc.amount, + "type": "biosphere", + "unit": exc.substance.unit, + }) + + activity = { + "name": dpp_process.name, + "reference product": str(dpp_process.functional_flow), + "unit": dpp_process.functional_flow.model.unit, + "location": location, + "stage": stage, + "comment": dpp_process.description, + "exchanges": exchanges, + } + bw_activities[(db_name, dpp_process.pk)] = activity + return bw_activities + +def convert_bw_to_dpp(bw_activity): + # Import here to avoid circular imports + from .models import BackgroundProcess + + raise NotImplementedError() + (db_name, code), act = bw_activity + dpp_activity = BackgroundProcess(name=act.name, amount=1, description=act.comment, functional_flow=act.reference_product, database=db_name, db_code=code) + for exchange in act.get('exchanges', []): + if exchange.get('type') == 'technosphere': + dpp_activity.amount = exchange['amount'] + else: + pass #TODO: create an exchange + return dpp_activity + +def link_to_background_db(activities, background_db): #FIXME: unused + """ + Link DPP processes to ecoinvent or other background DB + only for processes not in DPP system. + """ + for activity in activities: + for exchange in activity.get('exchanges', []): + if exchange.get('type') == 'technosphere': + # If not in foreground, search background + if not exchange.get('input'): + background_match = background_db.search( + exchange['name'], exchange.get('unit') + ) + if background_match: + exchange['input'] = background_match + +def select_supply_chain(root_product, max_depth=None): + """ + Traverse DPP links to build minimal Brightway database + for a specific product's supply chain. + """ + visited = set() + processes_to_include = [] + + def traverse(flow, depth=0): + if (max_depth and depth > max_depth) or flow.id in visited: + return + visited.add(flow.id) + + # Get the production process for this item + assert hasattr(flow, 'manufacturing_info'), f"Product {flow} has no production process!" + process = flow.manufacturing_info + processes_to_include.append(process) + # convert_dpp_to_bw_activity(process, db_name) + + # Traverse upstream through exchanges + if hasattr(process, 'prod_exchanges'): + for exchange in process.prod_exchanges.all(): + traverse(exchange.product, depth + 1) + + traverse(root_product) + return processes_to_include + +def lca_calculations(activity, family: str = 'EF v3.1'): + """Calculate LCA results for 1 unit of activity output. CC-BY EMPA + + :param activity: Brigtway activity + :param family (str): Name of a LCIA method family + """ + # Select methods belonging to family + methods = [ + m for m in bwd.methods + if m[0] == family and m not in EXCLUDED_METHODS + ] + if not methods: + print(f"⚠️ No {family} methods available.") + return + methods = sorted(methods) + # Calculate LCA results + lca = activity.lca(methods[0]) + results = [(methods[0], lca.score, bwd.methods[methods[0]].get('unit'))] + for m in methods[1:]: + lca.switch_method(m) + lca.lcia() + results.append((m, lca.score, bwd.methods[m].get("unit"))) + print(f"\n{family} results for {activity['name']}:") + for m, val, unit in results: + print(f" {m[1]} -> {val:.6g} {unit}") + return results + +def create_supply_chain_lca(product): + """ + Create a SustainabilityEvaluation by doing LCA + for 1 unit of `product`. + + :param product: The final product for which to do LCA + :type product: Flow + """ + # Import here to avoid circular imports + from .models import SustainabilityEvaluation, SustainabilityScore, ImpactIndicator + + setup_project("L4M-DPP") + lcia_family = 'EF v3.1' + method_set = ensure_methods(lcia_family) + evaluation, created = SustainabilityEvaluation.objects.get_or_create( + product=product, + functional_amount=1, + system_boundaries='Cradle to gate LCA', + geographical_scope='c', + impact_assessment_method=lcia_family, # method_set, + software_used='Brightway + Lasers4MaaS tool', + allocation_method='', + ) + if not created: + SustainabilityScore.objects.filter().delete() + + # Create unique Brightway database + db_name = f"dpp_{product.model.name}_{product.pk}" + if db_name in bwd.databases: + merge_choice = prompt_choice( + f"Foreground DB '{db_name}' exists. Choose action:", + ["Add data", "Overwrite"], + default_index=0, + ) + if merge_choice == "Overwrite": + del bwd.databases[db_name] + else: + print("Adding to existing DB.") + db = bwd.Database(db_name) + else: + db = bwd.Database(db_name) + db.register() + + # Collect supply chain processes and load in Brightway DB + processes = select_supply_chain(product) + bw_activities = convert_dpp_to_brightway(processes, db.name) + db.write(bw_activities) + + # # Link to background database (ecoinvent, etc.) + # link_to_background_db(bw_activities, background_db) + + # Perform LCA + ref_activity = db.get(product.manufacturing_info.pk) + results = lca_calculations(ref_activity, lcia_family) + #TODO: contribution analysis + # Create SustainabilityScores to store results + if created: + for m, value, unit in results: + SustainabilityScore.objects.create( + impact_indicator=ImpactIndicator.objects.get(method=m,indicator_set=method_set), + evaluation=evaluation, + impact_value=value, + upstream_phase=0, + manufacturing_phase=0, + use_phase=0, + end_of_life_phase=0, + scope_1_2_3=0, + ) + else: + for m, value, unit in results: + SustainabilityScore.objects.update_or_create( + impact_indicator=ImpactIndicator.objects.get(method=m,indicator_set=method_set), + evaluation=evaluation, + impact_value=value, + upstream_phase=0, + manufacturing_phase=0, + use_phase=0, + end_of_life_phase=0, + scope_1_2_3=0, + ) + + return diff --git a/mysite/dpp/migrations/0005_add_activity_parent.py b/mysite/dpp/migrations/0005_add_activity_parent.py new file mode 100644 index 0000000..ca90655 --- /dev/null +++ b/mysite/dpp/migrations/0005_add_activity_parent.py @@ -0,0 +1,70 @@ +# Generated by Django 5.2.8 on 2025-11-24 13:34 + +from django.db import migrations, models +import django.db.models.deletion + + +def populate_activity_ptr(apps, schema_editor): + Process = apps.get_model('dpp', 'Process') + Activity = apps.get_model('dpp', 'Activity') + + for process in Process.objects.all(): + # Create the parent Activity row + activity = Activity.objects.create(id=process.pk, name=process.name) + # Link the new Activity to process + process.activity_ptr = activity + process.save(update_fields=['activity_ptr_id']) + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0004_alter_producttype_origin_and_more'), + ] + + operations = [ + # 0. Move SharedProcess from table to proxy model + migrations.DeleteModel(name='SharedProcess'), + migrations.CreateModel( + name='SharedProcess', + fields=[], + options={'proxy': True}, + bases=('dpp.Process',), + ), + + migrations.DeleteModel(name='ProductExchange'), + migrations.DeleteModel(name='EnvExchange'), + + # 3a. Create the new Activity table + migrations.CreateModel( + name='Activity', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ], + ), + # 3b. Add the activity_ptr field with a temporary default/null=True + migrations.AddField( + model_name='process', + name='activity_ptr', + field=models.OneToOneField( + null=True, # temporary + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + to='dpp.Activity', + ), + ), + # 3c. Populate the pointers + copy common data + migrations.RunPython(populate_activity_ptr, reverse_code=migrations.RunPython.noop), + + # 3c. Now make it non-nullable + migrations.AlterField( + model_name='process', + name='activity_ptr', + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + to='dpp.Activity', + ), + ), + ] \ No newline at end of file diff --git a/mysite/dpp/migrations/0006_backgroundprocess_and_more.py b/mysite/dpp/migrations/0006_backgroundprocess_and_more.py new file mode 100644 index 0000000..a2fd46b --- /dev/null +++ b/mysite/dpp/migrations/0006_backgroundprocess_and_more.py @@ -0,0 +1,285 @@ +# Generated by Django 5.2.8 on 2025-11-25 09:10 + +import datetime +import django.core.validators +import django.db.models.deletion +import django_countries.fields +import dpp.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0005_add_activity_parent'), + ] + + operations = [ + migrations.CreateModel( + name='BackgroundProcess', + fields=[ + ('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.activity')), + ('location', django_countries.fields.CountryField(blank=True, max_length=2)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Average market process', + 'verbose_name_plural': 'Average market processes', + }, + bases=('dpp.activity',), + ), + migrations.RemoveField( + model_name='producttype', + name='quality_compliance_documents', + ), + migrations.RemoveField( + model_name='producttype', + name='spare_parts_availability_duration', + ), + migrations.RemoveField( + model_name='producttype', + name='vendor_or_importer', + ), + migrations.RemoveField( + model_name='producttype', + name='origin', + ), + migrations.RemoveField( + model_name='producttype', + name='warranty_duration', + ), + migrations.RemoveField( + model_name='producttype', + name='takeback_system', + ), + migrations.AlterModelOptions( + name='activity', + options={'verbose_name_plural': 'Activities'}, + ), + migrations.AlterModelOptions( + name='billofmaterials', + options={'ordering': ['product', 'component'], 'verbose_name_plural': 'Bills of Materials'}, + ), + migrations.AlterModelOptions( + name='company', + options={'verbose_name_plural': 'Companies'}, + ), + migrations.AlterModelOptions( + name='impactcategory', + options={'verbose_name_plural': 'Impact Categories'}, + ), + migrations.AlterModelOptions( + name='metadata', + options={'verbose_name_plural': 'Metadata'}, + ), + migrations.AlterModelOptions( + name='process', + options={'verbose_name_plural': 'Processes'}, + ), + migrations.AlterModelOptions( + name='sharedprocess', + options={'verbose_name': 'Auxiliary process', 'verbose_name_plural': 'Auxiliary processes'}, + ), + migrations.RemoveField( + model_name='document', + name='file_type', + ), + migrations.RemoveField( + model_name='process', + name='id', + ), + migrations.RemoveField( + model_name='process', + name='name', + ), + migrations.RemoveField( + model_name='productionline', + name='id', + ), + migrations.RemoveField( + model_name='productionline', + name='name', + ), + migrations.AddField( + model_name='productionline', + name='activity_ptr', + field=models.OneToOneField(auto_created=True, default=1, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.activity'), + preserve_default=False, + ), + migrations.AlterField( + model_name='activity', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='billofmaterials', + name='unit', + field=models.CharField(choices=[('pcs', 'pieces'), ('Mass', [('kg', 'kg'), ('g', 'g'), ('lb', 'lb'), ('oz', 'oz')]), ('Volume', [('l', 'liters'), ('cm3', 'cm3'), ('dm3', 'dm3'), ('m3', 'm3 (cubic meters)'), ('ft3', 'ft3 (cubic feet)')]), ('Energy', [('kWh', 'kWh'), ('MWh', 'MWh'), ('MJ', 'MJ'), ('GJ', 'GJ')])], max_length=20), + ), + migrations.AlterField( + model_name='document', + name='instructions', + field=models.ManyToManyField(blank=True, help_text='Select all that apply. Instructions included in this document (ony for manauals)', to='dpp.instruction'), + ), + migrations.AlterField( + model_name='impactcategory', + name='is_environmental', + field=models.BooleanField(choices=[(True, 'Environmental'), (False, 'Socioeconomic')], default=True, verbose_name='Type of impact'), + ), + migrations.AlterField( + model_name='metadata', + name='access_link', + field=models.URLField(blank=True, help_text='URL to full DPP record.'), + ), + migrations.AlterField( + model_name='metadata', + name='access_policy', + field=models.URLField(blank=True, help_text='URL to data access terms and conditions.'), + ), + migrations.AlterField( + model_name='metadata', + name='audit_trail_mechanism', + field=models.SmallIntegerField(choices=[(0, 'None'), (1, 'Log files'), (2, 'Immutable ledger')], default=0), + ), + migrations.AlterField( + model_name='metadata', + name='issuer', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dpp.company', verbose_name='Responsible Economic Operator'), + ), + migrations.AlterField( + model_name='metadata', + name='storage_location', + field=models.SmallIntegerField(choices=[(0, 'Undeclared'), (1, 'On-premise server'), (2, 'Commercial cloud server'), (3, 'Centralized certified server'), (4, 'Decentralized storage')], default=0), + ), + migrations.AlterField( + model_name='process', + name='activity_ptr', + field=models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.activity'), + ), + migrations.AlterField( + model_name='process', + name='is_outsourced', + field=models.BooleanField(default=False, verbose_name='Outsourced'), + ), + migrations.AlterField( + model_name='productionline', + name='final_product', + field=models.OneToOneField(on_delete=django.db.models.deletion.RESTRICT, to='dpp.producttype'), + ), + migrations.AlterField( + model_name='productionline', + name='operator', + field=models.ForeignKey(blank=True, null=True, on_delete=models.SET(dpp.models.get_unknown_company), to='dpp.company'), + ), + migrations.AlterField( + model_name='producttype', + name='unit', + field=models.CharField(choices=[('pcs', 'pieces'), ('Mass', [('kg', 'kg'), ('g', 'g'), ('lb', 'lb'), ('oz', 'oz')]), ('Volume', [('l', 'liters'), ('cm3', 'cm3'), ('dm3', 'dm3'), ('m3', 'm3 (cubic meters)'), ('ft3', 'ft3 (cubic feet)')]), ('Energy', [('kWh', 'kWh'), ('MWh', 'MWh'), ('MJ', 'MJ'), ('GJ', 'GJ')])], default='pcs', max_length=3), + ), + migrations.AlterField( + model_name='producttype', + name='volume_unit', + field=models.CharField(choices=[('l', 'liters'), ('cm3', 'cm3'), ('dm3', 'dm3'), ('m3', 'm3 (cubic meters)'), ('ft3', 'ft3 (cubic feet)')], default='m3', max_length=3), + ), + migrations.AlterField( + model_name='secondaryproduct', + name='is_waste', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='Alias', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('alt_name', models.CharField(max_length=100, verbose_name='Display name')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alias', to='dpp.producttype')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dpp.company')), + ], + options={ + 'verbose_name_plural': 'Aliases', + }, + ), + migrations.CreateModel( + name='SustainabilityEvaluation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('functional_amount', models.FloatField()), + ('system_boundaries', models.CharField(blank=True, max_length=200)), + ('geographical_scope', models.CharField(blank=True, choices=[('EU', 'European Union (EU)'), ('c', 'Country-specific'), ('glo', 'Global'), ('-', 'Other')], max_length=4)), + ('temporal_scope', models.CharField(default='', max_length=50)), + ('impact_assessment_method', models.CharField(blank=True, help_text='Specify the environmental impact assessment method. E.g. EF 3.0, ReCiPe, ILCD, TRACI.', max_length=50)), + ('software_used', models.CharField(blank=True, help_text='Indicate the assessment software used. E.g. OpenLCA, GaBi, SimaPro, Umberto.', max_length=50)), + ('allocation_method', models.CharField(blank=True, choices=[('mass', 'Mass-based'), ('econom', 'Economic (price-based)'), ('energy', 'Energy-based'), ('other', 'Other')], help_text='How are impacts allocated for co-production processes?', max_length=6)), + ('assessment_date', models.DateField(default=datetime.date.today)), + ('assessed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dpp.institution')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dpp.producttype')), + ], + ), + migrations.AlterField( + model_name='sustainabilityscore', + name='evaluation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dpp.sustainabilityevaluation'), + ), + migrations.CreateModel( + name='DppProduct', + fields=[ + ('producttype_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.producttype')), + ('warranty_period_months', models.PositiveSmallIntegerField(default=0, help_text='Warranty period in months')), + ('warranty_duration', models.DecimalField(decimal_places=1, default=0, help_text='Warranty period in years', max_digits=3, validators=[django.core.validators.MinValueValidator(0)])), + ('spare_parts_availability_duration', models.DecimalField(decimal_places=1, default=0, help_text='Spare parts availability in years', max_digits=3, validators=[django.core.validators.MinValueValidator(0)])), + ('takeback_system', models.CharField(choices=[('no', 'No take-back system'), ('basic', 'Collection on request'), ('active', 'Structured take-back with dedicated channels or collection points'), ('advanced', 'Certified, traceable take-back system')], default='no', max_length=10)), + ('origin', models.ForeignKey(on_delete=models.SET(dpp.models.get_unknown_company), related_name='manufactured_products', to='dpp.company')), + ('quality_compliance_documents', models.ManyToManyField(blank=True, to='dpp.document')), + ('vendor_or_importer', models.ForeignKey(blank=True, null=True, on_delete=models.SET(dpp.models.get_unknown_importer), related_name='sold_products', to='dpp.importer')), + ], + bases=('dpp.producttype',), + ), + migrations.CreateModel( + name='EnvExchange', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.FloatField()), + ('exchange_type', models.CharField(choices=[('in', 'Input'), ('out', 'Output'), ('ff', 'functional flow')], max_length=3)), + ('is_proxy', models.BooleanField(default=False, verbose_name='This is an approximation of the actual product')), + ('observed', models.BooleanField(choices=[(True, 'Measured'), (False, 'Modeled or calculated')], default=False, verbose_name='Quantity is')), + ('uncertainty_type', models.CharField(choices=[('none', 'No uncertainty'), ('na', 'Not available'), ('interval', 'Interval (min-max)'), ('normal', 'Normal distribution'), ('lognormal istribution', 'Lognormal distribution'), ('triangular', 'Triangular distribution')], default='none', max_length=30)), + ('loc', models.FloatField(blank=True)), + ('scale', models.FloatField(blank=True)), + ('shape', models.FloatField(blank=True)), + ('minimum', models.FloatField(blank=True)), + ('maximum', models.FloatField(blank=True)), + ('compartment', models.CharField(choices=[('air', 'air'), ('soil', 'soil'), ('groundwater', 'groundwater'), ('seawater', 'seawater'), ('surface_water', 'surface water')], max_length=20)), + ('process', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='env_exchanges', to='dpp.process')), + ('substance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dpp.emission')), + ], + options={ + 'ordering': ['process', 'substance'], + 'unique_together': {('substance', 'compartment', 'exchange_type')}, + }, + ), + migrations.CreateModel( + name='ProductExchange', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.FloatField()), + ('exchange_type', models.CharField(choices=[('in', 'Input'), ('out', 'Output'), ('ff', 'functional flow')], max_length=3)), + ('is_proxy', models.BooleanField(default=False, verbose_name='This is an approximation of the actual product')), + ('observed', models.BooleanField(choices=[(True, 'Measured'), (False, 'Modeled or calculated')], default=False, verbose_name='Quantity is')), + ('uncertainty_type', models.CharField(choices=[('none', 'No uncertainty'), ('na', 'Not available'), ('interval', 'Interval (min-max)'), ('normal', 'Normal distribution'), ('lognormal istribution', 'Lognormal distribution'), ('triangular', 'Triangular distribution')], default='none', max_length=30)), + ('loc', models.FloatField(blank=True)), + ('scale', models.FloatField(blank=True)), + ('shape', models.FloatField(blank=True)), + ('minimum', models.FloatField(blank=True)), + ('maximum', models.FloatField(blank=True)), + ('process', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prod_exchanges', to='dpp.activity')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_in_process', to='dpp.producttype')), + ], + options={ + 'ordering': ['process', 'product'], + 'unique_together': {('product', 'process', 'exchange_type')}, + }, + ), + migrations.DeleteModel( + name='SustainablityEvaluation', + ), + ] diff --git a/mysite/dpp/migrations/0007_backgroundprocess_database_and_more.py b/mysite/dpp/migrations/0007_backgroundprocess_database_and_more.py new file mode 100644 index 0000000..d6f1e82 --- /dev/null +++ b/mysite/dpp/migrations/0007_backgroundprocess_database_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.8 on 2025-11-26 12:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0006_backgroundprocess_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='backgroundprocess', + name='database', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + ] diff --git a/mysite/dpp/migrations/0008_alter_process_functional_flow_and_more.py b/mysite/dpp/migrations/0008_alter_process_functional_flow_and_more.py new file mode 100644 index 0000000..76b32ec --- /dev/null +++ b/mysite/dpp/migrations/0008_alter_process_functional_flow_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.8 on 2025-11-26 12:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0007_backgroundprocess_database_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='process', + name='functional_flow', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='produced_by', to='dpp.producttype'), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + ] diff --git a/mysite/dpp/migrations/0009_backgroundprocess_functional_flow_and_more.py b/mysite/dpp/migrations/0009_backgroundprocess_functional_flow_and_more.py new file mode 100644 index 0000000..2537819 --- /dev/null +++ b/mysite/dpp/migrations/0009_backgroundprocess_functional_flow_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.8 on 2025-11-26 14:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0008_alter_process_functional_flow_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='backgroundprocess', + name='functional_flow', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='produced_by_other', to='dpp.producttype', verbose_name='Main product'), + ), + migrations.AlterField( + model_name='process', + name='functional_flow', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='produced_by', to='dpp.producttype', verbose_name='Main product'), + ), + migrations.AlterField( + model_name='productionline', + name='final_product', + field=models.OneToOneField(on_delete=django.db.models.deletion.RESTRICT, to='dpp.producttype', verbose_name='Final Product'), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + ] diff --git a/mysite/dpp/migrations/0010_alter_envexchange_options_and_more.py b/mysite/dpp/migrations/0010_alter_envexchange_options_and_more.py new file mode 100644 index 0000000..7b9bce1 --- /dev/null +++ b/mysite/dpp/migrations/0010_alter_envexchange_options_and_more.py @@ -0,0 +1,117 @@ +# Generated by Django 5.2.8 on 2025-11-27 14:11 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0009_backgroundprocess_functional_flow_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='envexchange', + options={'ordering': ['process', 'substance'], 'verbose_name': 'Emissions & Extractions'}, + ), + migrations.AlterModelOptions( + name='impactcategory', + options={'verbose_name_plural': 'Impact categories'}, + ), + migrations.RenameField( + model_name='dppproduct', + old_name='warranty_duration', + new_name='warranty_period', + ), + migrations.RemoveField( + model_name='dppproduct', + name='warranty_period_months', + ), + migrations.AlterField( + model_name='envexchange', + name='loc', + field=models.FloatField(blank=True, null=True, verbose_name='Mean or median'), + ), + migrations.AlterField( + model_name='envexchange', + name='maximum', + field=models.FloatField(blank=True, help_text='for interval and triangular distribution', null=True), + ), + migrations.AlterField( + model_name='envexchange', + name='minimum', + field=models.FloatField(blank=True, help_text='for interval and triangular distribution', null=True), + ), + migrations.AlterField( + model_name='envexchange', + name='scale', + field=models.FloatField(blank=True, null=True, verbose_name='Standard deviation'), + ), + migrations.AlterField( + model_name='envexchange', + name='shape', + field=models.FloatField(blank=True, help_text='for lognormal distribution', null=True), + ), + migrations.AlterField( + model_name='envexchange', + name='substance', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exchanges', to='dpp.emission'), + ), + migrations.AlterField( + model_name='hazardousmaterial', + name='concentration_unit', + field=models.CharField(choices=[('wt', 'Weight fraction')], max_length=20), + ), + migrations.AlterField( + model_name='hazardousmaterial', + name='substance_concentration', + field=models.FloatField(blank=True, default=1, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1)]), + ), + migrations.AlterField( + model_name='material', + name='density', + field=models.FloatField(blank=True, default=0), + ), + migrations.AlterField( + model_name='product', + name='batch_number', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='productexchange', + name='loc', + field=models.FloatField(blank=True, null=True, verbose_name='Mean or median'), + ), + migrations.AlterField( + model_name='productexchange', + name='maximum', + field=models.FloatField(blank=True, help_text='for interval and triangular distribution', null=True), + ), + migrations.AlterField( + model_name='productexchange', + name='minimum', + field=models.FloatField(blank=True, help_text='for interval and triangular distribution', null=True), + ), + migrations.AlterField( + model_name='productexchange', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exchanged_by', to='dpp.producttype'), + ), + migrations.AlterField( + model_name='productexchange', + name='scale', + field=models.FloatField(blank=True, null=True, verbose_name='Standard deviation'), + ), + migrations.AlterField( + model_name='productexchange', + name='shape', + field=models.FloatField(blank=True, help_text='for lognormal distribution', null=True), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + ] diff --git a/mysite/dpp/migrations/0011_alter_circularityindicator_id_and_more.py b/mysite/dpp/migrations/0011_alter_circularityindicator_id_and_more.py new file mode 100644 index 0000000..23b5c71 --- /dev/null +++ b/mysite/dpp/migrations/0011_alter_circularityindicator_id_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.8 on 2025-11-27 16:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0010_alter_envexchange_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='circularityindicator', + name='id', + field=models.CharField(max_length=6, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + migrations.DeleteModel( + name='OldCircularityIndicator', + ), + ] diff --git a/mysite/dpp/migrations/0012_alter_billofmaterials_unit_alter_company_vat_number_and_more.py b/mysite/dpp/migrations/0012_alter_billofmaterials_unit_alter_company_vat_number_and_more.py new file mode 100644 index 0000000..7e063bf --- /dev/null +++ b/mysite/dpp/migrations/0012_alter_billofmaterials_unit_alter_company_vat_number_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.8 on 2025-12-04 08:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0011_alter_circularityindicator_id_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='billofmaterials', + name='unit', + field=models.CharField(choices=[('pcs', 'pieces'), ('Mass', [('kg', 'kg'), ('g', 'g'), ('lb', 'lb'), ('oz', 'oz')]), ('Volume', [('l', 'liters'), ('cm3', 'cm3'), ('dm3', 'dm3'), ('m3', 'm3 (cubic meters)'), ('ft3', 'ft3 (cubic feet)')]), ('Energy', [('kWh', 'kWh'), ('MWh', 'MWh'), ('MJ', 'MJ'), ('GJ', 'GJ')])], max_length=20), + ), + migrations.AlterField( + model_name='company', + name='vat_number', + field=models.CharField(blank=True, max_length=50, verbose_name='VAT number'), + ), + migrations.AlterField( + model_name='producttype', + name='unit', + field=models.CharField(choices=[('pcs', 'pieces'), ('Mass', [('kg', 'kg'), ('g', 'g'), ('lb', 'lb'), ('oz', 'oz')]), ('Volume', [('l', 'liters'), ('cm3', 'cm3'), ('dm3', 'dm3'), ('m3', 'm3 (cubic meters)'), ('ft3', 'ft3 (cubic feet)')]), ('Energy', [('kWh', 'kWh'), ('MWh', 'MWh'), ('MJ', 'MJ'), ('GJ', 'GJ')])], default='pcs', max_length=3), + ), + migrations.AlterField( + model_name='producttype', + name='volume_unit', + field=models.CharField(choices=[('l', 'liters'), ('cm3', 'cm3'), ('dm3', 'dm3'), ('m3', 'm3 (cubic meters)'), ('ft3', 'ft3 (cubic feet)')], default='m3', max_length=3), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + ] diff --git a/mysite/dpp/migrations/0013_process_amount_alter_producttype_unit_and_more.py b/mysite/dpp/migrations/0013_process_amount_alter_producttype_unit_and_more.py new file mode 100644 index 0000000..be63eb1 --- /dev/null +++ b/mysite/dpp/migrations/0013_process_amount_alter_producttype_unit_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.8 on 2025-12-05 08:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0012_alter_billofmaterials_unit_alter_company_vat_number_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='process', + name='amount', + field=models.FloatField(default=1, help_text='Number of units produced'), + ), + migrations.AlterField( + model_name='producttype', + name='unit', + field=models.CharField(default='pcs', help_text='How the product is counted, e.g. pcs, bottles, sheets, bags', max_length=15), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + ] diff --git a/mysite/dpp/migrations/0014_emission_unit_and_more.py b/mysite/dpp/migrations/0014_emission_unit_and_more.py new file mode 100644 index 0000000..526e8cf --- /dev/null +++ b/mysite/dpp/migrations/0014_emission_unit_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.8 on 2025-12-05 09:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0013_process_amount_alter_producttype_unit_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='emission', + name='unit', + field=models.CharField(default='g', max_length=10), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + ] diff --git a/mysite/dpp/migrations/0015_indicatorset_rename_product_productitem_and_more.py b/mysite/dpp/migrations/0015_indicatorset_rename_product_productitem_and_more.py new file mode 100644 index 0000000..2742750 --- /dev/null +++ b/mysite/dpp/migrations/0015_indicatorset_rename_product_productitem_and_more.py @@ -0,0 +1,174 @@ +# Generated by Django 5.2.8 on 2025-12-10 12:36 + +import django.core.validators +import django.db.models.deletion +import django_countries.fields +import dpp.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0014_emission_unit_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='IndicatorSet', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('start_date', models.DateField(verbose_name='Release date')), + ('end_date', models.DateField(blank=True, null=True, verbose_name='Phase-out date')), + ], + ), + migrations.RenameModel( + old_name='Product', + new_name='ProductItem', + ), + migrations.RenameModel( + old_name='ReplacedComponents', + new_name='ReplacedComponent', + ), + migrations.AlterModelOptions( + name='envexchange', + options={'ordering': ['process', 'substance'], 'verbose_name': 'Emission or Extraction', 'verbose_name_plural': 'Emissions & Extractions'}, + ), + migrations.RenameField( + model_name='envexchange', + old_name='exchange_type', + new_name='direction', + ), + migrations.RenameField( + model_name='productexchange', + old_name='exchange_type', + new_name='direction', + ), + migrations.AlterUniqueTogether( + name='envexchange', + unique_together={('substance', 'compartment', 'direction')}, + ), + migrations.AlterUniqueTogether( + name='productexchange', + unique_together={('product', 'process', 'direction')}, + ), + migrations.RemoveField( + model_name='composition', + name='fraction', + ), + migrations.RemoveField( + model_name='criticalrawmaterial', + name='origin_country', + ), + migrations.RemoveField( + model_name='impactcategory', + name='description', + ), + migrations.RemoveField( + model_name='impactcategory', + name='is_environmental', + ), + migrations.RemoveField( + model_name='impactcategory', + name='unit', + ), + migrations.AddField( + model_name='composition', + name='origin_country', + field=django_countries.fields.CountryField(blank=True, help_text='Only fill for Critical Raw Materials', max_length=2, null=True), + ), + migrations.AddField( + model_name='composition', + name='quantity', + field=models.FloatField(default=0, help_text='The amount of material present in product.'), + preserve_default=False, + ), + migrations.AlterField( + model_name='composition', + name='material', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='used_in', to='dpp.material'), + ), + migrations.AlterField( + model_name='envexchange', + name='uncertainty_type', + field=models.CharField(choices=[('none', 'No uncertainty'), ('na', 'Not available'), ('interval', 'Interval (min-max)'), ('normal', 'Normal distribution'), ('lognormal istribution', 'Lognormal distribution'), ('triangular', 'Triangular distribution')], default='none', help_text='If the amount is uncertain, how can this uncertainty be described?', max_length=30), + ), + migrations.AlterField( + model_name='organization', + name='legal_documents', + field=models.ForeignKey(blank=True, help_text='Add official legal documentation associated with the company. This may include licenses, registration papers, permits, or other legally mandated certificates.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='organization_legal_documents', to='dpp.document'), + ), + migrations.AlterField( + model_name='process', + name='functional_flow', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='produced_by', to='dpp.producttype', verbose_name='Main output'), + ), + migrations.AlterField( + model_name='productexchange', + name='uncertainty_type', + field=models.CharField(choices=[('none', 'No uncertainty'), ('na', 'Not available'), ('interval', 'Interval (min-max)'), ('normal', 'Normal distribution'), ('lognormal istribution', 'Lognormal distribution'), ('triangular', 'Triangular distribution')], default='none', help_text='If the amount is uncertain, how can this uncertainty be described?', max_length=30), + ), + migrations.AlterField( + model_name='productionline', + name='energy_balance', + field=models.ForeignKey(blank=True, help_text='Add a document showing all energy flows going in and out of the production line. (Optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='energy_balance', to='dpp.document'), + ), + migrations.AlterField( + model_name='productionline', + name='final_product', + field=models.OneToOneField(help_text='The output product of this production line', on_delete=django.db.models.deletion.RESTRICT, to='dpp.producttype', verbose_name='Final product'), + ), + migrations.AlterField( + model_name='productionline', + name='mass_balance', + field=models.ForeignKey(blank=True, help_text='Add a document showing all material flows going in and out of the production line. (Optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mass_balance', to='dpp.document'), + ), + migrations.AlterField( + model_name='productionline', + name='operator', + field=models.ForeignKey(blank=True, null=True, on_delete=models.SET(dpp.models.get_unknown_company), to='dpp.company', verbose_name='Producing company'), + ), + migrations.AlterField( + model_name='producttype', + name='hs_code', + field=models.CharField(blank=True, help_text='Harmonized System classification (customs code)', max_length=10, verbose_name='HS code'), + ), + migrations.AlterField( + model_name='producttype', + name='unit', + field=models.CharField(default='pcs', help_text='How the product is counted, e.g. pcs, bottles, sheets, kWh', max_length=15), + ), + migrations.AlterField( + model_name='producttype', + name='unit_price', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True), + ), + migrations.AlterField( + model_name='producttype', + name='weight', + field=models.FloatField(blank=True, default=1, null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Weight of 1 unit'), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + migrations.CreateModel( + name='ImpactIndicator', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('method', models.CharField(max_length=50)), + ('description', models.CharField(blank=True, max_length=200)), + ('unit', models.CharField(max_length=40)), + ('is_environmental', models.BooleanField(choices=[(True, 'Environmental'), (False, 'Socioeconomic')], default=True, verbose_name='Type of impact')), + ('impact_category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dpp.impactcategory')), + ('indicator_set', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dpp.indicatorset')), + ], + ), + migrations.AlterField( + model_name='sustainabilityscore', + name='impact_category', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dpp.impactindicator'), + ), + ] diff --git a/mysite/dpp/migrations/0016_restructure_products_and_more.py b/mysite/dpp/migrations/0016_restructure_products_and_more.py new file mode 100644 index 0000000..3d2d514 --- /dev/null +++ b/mysite/dpp/migrations/0016_restructure_products_and_more.py @@ -0,0 +1,162 @@ +# Generated by Django 5.2.8 on 2025-12-12 10:05 + +import django.core.validators +import django.db.models.deletion +import dpp.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0015_indicatorset_rename_product_productitem_and_more'), + ] + + operations = [ + migrations.DeleteModel(name='dppproduct'), + migrations.RemoveField( + model_name='packaginginfo', + name='packaging', + ), + migrations.RemoveField( + model_name='packaginginfo', + name='product', + ), + migrations.RemoveField( + model_name='secondaryproduct', + name='producttype_ptr', + ), + migrations.DeleteModel(name='Packaging'), + migrations.DeleteModel(name='PackagingInfo'), + migrations.DeleteModel(name='secondaryproduct'), + migrations.RenameModel('ProductType', 'ProductModel'), + migrations.RemoveField( + model_name='productmodel', + name='volume', + ), + migrations.RemoveField( + model_name='productmodel', + name='volume_unit', + ), + migrations.RemoveField( + model_name='productmodel', + name='weight', + ), + migrations.RemoveField( + model_name='productmodel', + name='weight_unit', + ), + migrations.RemoveField( + model_name='productitem', + name='product_type', + ), + migrations.RemoveField( + model_name='productitem', + name='batch_number', + ), + migrations.CreateModel( + name='SecondaryProduct', + fields=[ + ('productmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.productmodel')), + ('circularity', models.CharField(choices=[('R3', 'reused'), ('R5', 'refurbished'), ('R6', 'remanufactured'), ('R7', 'repurposed'), ('R8', 'recycled'), ('in', 'incinerated'), ('lf', 'landfilled'), ('-', 'unknown')], default='-', max_length=2)), + ('is_waste', models.BooleanField(default=False)), + ], + bases=('dpp.productmodel',), + ), + migrations.AddField( + model_name='productexchange', + name='type', + field=models.CharField(choices=[('prod', 'Component (added to the product)'), ('cons', 'Consumable'), ('ener', 'Electricity or heat'), ('util', 'Utility or equipment'), ('waste', 'Waste')], default='prod', max_length=5), + preserve_default=False, + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + migrations.CreateModel( + name='ProductBatch', + fields=[ + ('productmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.productmodel')), + ('batch_number', models.PositiveIntegerField()), + ('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='batch', to='dpp.productmodel')), + ], + bases=('dpp.productmodel',), + ), + migrations.AlterField( + model_name='alias', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alias', to='dpp.productmodel'), + ), + migrations.AlterField( + model_name='backgroundprocess', + name='functional_flow', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='produced_by_other', to='dpp.productmodel', verbose_name='Main product'), + ), + migrations.AlterField( + model_name='productexchange', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exchanged_by', to='dpp.productmodel'), + ), + migrations.CreateModel( + name='ProductProperties', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('weight', models.FloatField(validators=[django.core.validators.MinValueValidator(0)], verbose_name='Weight of 1 unit')), + ('weight_unit', models.CharField(choices=[('kg', 'kg'), ('g', 'g'), ('lb', 'lb'), ('oz', 'oz')], default='kg', max_length=2)), + ('volume', models.FloatField(validators=[django.core.validators.MinValueValidator(0)])), + ('volume_unit', models.CharField(choices=[('l', 'liters'), ('cm3', 'cm3'), ('dm3', 'dm3'), ('m3', 'm3 (cubic meters)'), ('ft3', 'ft3 (cubic feet)')], default='m3', max_length=3)), + ('density', models.FloatField(help_text='Density of the product, excluding packaging and empty space.', validators=[django.core.validators.MinValueValidator(0)])), + ('product', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='properties', to='dpp.productmodel')), + ], + ), + migrations.CreateModel( + name='DppDetails', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('warranty_period', models.DecimalField(decimal_places=1, default=0, help_text='Warranty period in years', max_digits=3, validators=[django.core.validators.MinValueValidator(0)])), + ('spare_parts_availability_duration', models.DecimalField(decimal_places=1, default=0, help_text='Spare parts availability in years', max_digits=3, validators=[django.core.validators.MinValueValidator(0)])), + ('takeback_system', models.CharField(choices=[('no', 'No take-back system'), ('basic', 'Collection on request'), ('active', 'Structured take-back with dedicated channels or collection points'), ('advanced', 'Certified, traceable take-back system')], default='no', max_length=10)), + ('origin', models.ForeignKey(on_delete=models.SET(dpp.models.get_unknown_company), related_name='manufactured_products', to='dpp.company')), + ('product', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='dpp.productmodel')), + ('quality_compliance_documents', models.ManyToManyField(blank=True, to='dpp.document')), + ('vendor_or_importer', models.ForeignKey(blank=True, null=True, on_delete=models.SET(dpp.models.get_unknown_importer), related_name='sold_products', to='dpp.importer')), + ], + ), + migrations.AddField( + model_name='productitem', + name='product_batch', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.PROTECT, to='dpp.productbatch'), + preserve_default=False, + ), + migrations.AlterField( + model_name='billofmaterials', + name='component', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='part_of', to='dpp.productmodel'), + ), + migrations.AlterField( + model_name='billofmaterials', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bom', to='dpp.productmodel'), + ), + migrations.AlterField( + model_name='circularityevaluation', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dpp.productmodel'), + ), + migrations.AlterField( + model_name='composition', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='composition', to='dpp.productmodel'), + ), + migrations.AlterField( + model_name='process', + name='functional_flow', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='produced_by', to='dpp.productmodel', verbose_name='Main output'), + ), + migrations.AlterField( + model_name='productionline', + name='final_product', + field=models.OneToOneField(help_text='The output product of this production line', on_delete=django.db.models.deletion.RESTRICT, to='dpp.productmodel', verbose_name='Final product'), + ), + ] diff --git a/mysite/dpp/migrations/0017_add_property_n_units.py b/mysite/dpp/migrations/0017_add_property_n_units.py new file mode 100644 index 0000000..1d8c436 --- /dev/null +++ b/mysite/dpp/migrations/0017_add_property_n_units.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.8 on 2025-12-12 13:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0016_restructure_products_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='productproperties', + name='includes_packaging', + field=models.BooleanField(default=False, verbose_name='The above includes packaging'), + ), + migrations.AlterField( + model_name='billofmaterials', + name='unit', + field=models.CharField(choices=[('pcs', 'pieces'), ('Mass', [('kg', 'kg'), ('g', 'g'), ('lb', 'lb'), ('oz', 'oz')]), ('Volume', [('l', 'liters'), ('cm3', 'cm3'), ('dm3', 'dm3'), ('m3', 'm3 (cubic meters)'), ('ft3', 'ft3 (cubic feet)'), ('gal', 'gallons')]), ('Energy', [('kWh', 'kWh'), ('MWh', 'MWh'), ('MJ', 'MJ'), ('GJ', 'GJ')])], max_length=20), + ), + migrations.AlterField( + model_name='productexchange', + name='type', + field=models.CharField(choices=[('prod', 'Component (added to the product)'), ('cons', 'Consumable'), ('pack', 'Packaging'), ('ener', 'Electricity or heat'), ('util', 'Utility or equipment'), ('waste', 'Waste')], max_length=5), + ), + migrations.AlterField( + model_name='productproperties', + name='volume_unit', + field=models.CharField(choices=[('l', 'liters'), ('cm3', 'cm3'), ('dm3', 'dm3'), ('m3', 'm3 (cubic meters)'), ('ft3', 'ft3 (cubic feet)'), ('gal', 'gallons')], default='m3', max_length=3), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + ] diff --git a/mysite/dpp/migrations/0018_alter_process_functional_flow_and_more.py b/mysite/dpp/migrations/0018_alter_process_functional_flow_and_more.py new file mode 100644 index 0000000..8a72cb8 --- /dev/null +++ b/mysite/dpp/migrations/0018_alter_process_functional_flow_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.8 on 2025-12-16 13:49 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0017_add_property_n_units'), + ] + + operations = [ + migrations.AlterField( + model_name='process', + name='functional_flow', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='produced_by', to='dpp.productmodel', verbose_name='Main output'), + ), + migrations.AlterField( + model_name='process', + name='production_line', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bop', to='dpp.productionline'), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + migrations.CreateModel( + name='Transport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('distance', models.PositiveSmallIntegerField(default=0, validators=[django.core.validators.MaxValueValidator(40000)], verbose_name='Transport distance (km)')), + ('mode', models.CharField(choices=[('ocean', 'Ship (ocean)'), ('NA', 'Unspecified')], default='NA', max_length=10, verbose_name='Main mode of transport')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transport', to='dpp.productmodel')), + ('production_line', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transport', to='dpp.productionline')), + ], + ), + migrations.DeleteModel( + name='BillOfMaterials', + ), + ] diff --git a/mysite/dpp/migrations/0019_alter_material_options_and_more.py b/mysite/dpp/migrations/0019_alter_material_options_and_more.py new file mode 100644 index 0000000..087cd69 --- /dev/null +++ b/mysite/dpp/migrations/0019_alter_material_options_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.8 on 2025-12-17 15:41 + +import django_countries.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0018_alter_process_functional_flow_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='material', + options={'ordering': ['name', 'origin_country']}, + ), + migrations.RemoveField( + model_name='composition', + name='origin_country', + ), + migrations.RemoveField( + model_name='hazardousmaterial', + name='concentration_unit', + ), + migrations.RemoveField( + model_name='hazardousmaterial', + name='substance_concentration', + ), + migrations.RemoveField( + model_name='hazardousmaterial', + name='substance_location', + ), + migrations.AddField( + model_name='composition', + name='unit', + field=models.CharField(choices=[('kg', 'kg'), ('g', 'g'), ('lb', 'lb'), ('oz', 'oz')], default='g', max_length=2), + ), + migrations.AddField( + model_name='material', + name='criticality_level', + field=models.CharField(blank=True, choices=[('', 'N/A'), ('c', 'critical'), ('h', 'high'), ('m', 'intermediate')], default='', help_text='Only for Critical Raw Materials (CRMs): criticality indicator based on supply risk and economic importance.', max_length=1), + ), + migrations.AddField( + model_name='material', + name='origin_country', + field=django_countries.fields.CountryField(blank=True, help_text='Only for Critical Raw Materials (CRMs)', max_length=2, null=True, verbose_name='Country of origin'), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + migrations.DeleteModel( + name='CriticalRawMaterial', + ), + ] diff --git a/mysite/dpp/migrations/0020_alter_dppdetails_options_alter_productbatch_options_and_more.py b/mysite/dpp/migrations/0020_alter_dppdetails_options_alter_productbatch_options_and_more.py new file mode 100644 index 0000000..c5a7aad --- /dev/null +++ b/mysite/dpp/migrations/0020_alter_dppdetails_options_alter_productbatch_options_and_more.py @@ -0,0 +1,76 @@ +# Generated by Django 5.2.8 on 2026-01-08 10:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0019_alter_material_options_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='dppdetails', + options={'verbose_name': 'DPP details', 'verbose_name_plural': 'DPP details'}, + ), + migrations.AlterModelOptions( + name='productbatch', + options={'verbose_name_plural': 'Product batches'}, + ), + migrations.AddField( + model_name='backgroundprocess', + name='amount', + field=models.FloatField(default=1, help_text='Number of units produced'), + ), + migrations.AddField( + model_name='backgroundprocess', + name='comment', + field=models.TextField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='backgroundprocess', + name='db_code', + field=models.CharField(blank=True, help_text='Unique ID in the source database', max_length=50), + ), + migrations.AddField( + model_name='backgroundprocess', + name='tags', + field=models.CharField(blank=True, max_length=150), + ), + migrations.AddField( + model_name='backgroundprocess', + name='type', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='document', + name='expiry_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='metadata', + name='language', + field=models.CharField(default='EN', help_text='Language used in descriptions', max_length=20), + ), + migrations.AddField( + model_name='productmodel', + name='brand', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AlterField( + model_name='productexchange', + name='type', + field=models.CharField(choices=[('prod', 'Component (added to the product)'), ('cons', 'Consumable'), ('ener', 'Electricity or heat'), ('util', 'Utility or equipment'), ('trans', 'Transport'), ('pack', 'Packaging'), ('react', 'Reactant'), ('waste', 'Waste')], max_length=5), + ), + migrations.AlterField( + model_name='productmodel', + name='taric_code', + field=models.CharField(blank=True, help_text='(customs code)', max_length=20, verbose_name='TARIC code'), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + ] diff --git a/mysite/dpp/migrations/0021_rename_upload_date_document_issue_date_and_more.py b/mysite/dpp/migrations/0021_rename_upload_date_document_issue_date_and_more.py new file mode 100644 index 0000000..07f3429 --- /dev/null +++ b/mysite/dpp/migrations/0021_rename_upload_date_document_issue_date_and_more.py @@ -0,0 +1,200 @@ +# Generated by Django 5.2.8 on 2026-01-12 10:29 + +import django.db.models.deletion +import django_countries.fields +import dpp.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0020_alter_dppdetails_options_alter_productbatch_options_and_more'), + ] + + operations = [ + migrations.DeleteModel( + name='DppDetails', + ), + migrations.RemoveField( + model_name='process', + name='production_line', + ), + migrations.RemoveField( + model_name='transport', + name='production_line', + ), + migrations.RenameField( + model_name='document', + old_name='upload_date', + new_name='issue_date', + ), + migrations.RenameField( + model_name='envexchange', + old_name='observed', + new_name='is_observed', + ), + migrations.RenameField( + model_name='material', + old_name='biobased_percentage', + new_name='biobased_fraction', + ), + migrations.RenameField( + model_name='material', + old_name='recyclable_percentage', + new_name='recyclable_fraction', + ), + migrations.RenameField( + model_name='material', + old_name='recycled_content', + new_name='recycled_fraction', + ), + migrations.RenameField( + model_name='productexchange', + old_name='observed', + new_name='is_observed', + ), + migrations.RemoveField( + model_name='backgroundprocess', + name='activity_ptr', + ), + migrations.RemoveField( + model_name='backgroundprocess', + name='amount', + ), + migrations.RemoveField( + model_name='backgroundprocess', + name='comment', + ), + migrations.RemoveField( + model_name='backgroundprocess', + name='functional_flow', + ), + migrations.RemoveField( + model_name='backgroundprocess', + name='location', + ), + migrations.RemoveField( + model_name='backgroundprocess', + name='modified_at', + ), + migrations.RemoveField( + model_name='metadata', + name='issuer', + ), + migrations.RemoveField( + model_name='process', + name='amount', + ), + migrations.RemoveField( + model_name='process', + name='location', + ), + migrations.RemoveField( + model_name='process', + name='operator', + ), + migrations.RemoveField( + model_name='productionline', + name='activity_ptr', + ), + migrations.RemoveField( + model_name='productitem', + name='CPV_code', + ), + migrations.RemoveField( + model_name='productitem', + name='GS1_GPC_code', + ), + migrations.AddField( + model_name='activity', + name='amount', + field=models.FloatField(default=1, help_text='Reference number of units produced'), + ), + migrations.AddField( + model_name='activity', + name='description', + field=models.TextField(blank=True, max_length=300), + ), + migrations.AddField( + model_name='activity', + name='location', + field=django_countries.fields.CountryField(blank=True, max_length=2), + ), + migrations.AddField( + model_name='activity', + name='operator', + field=models.ForeignKey(blank=True, null=True, on_delete=models.SET(dpp.models.get_unknown_company), to='dpp.company', verbose_name='Producing company'), + ), + migrations.AddField( + model_name='document', + name='issuer', + field=models.ForeignKey(blank=True, help_text='Author, issuer or publisher', null=True, on_delete=django.db.models.deletion.SET_NULL, to='dpp.organization'), + ), + migrations.AddField( + model_name='productionline', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AddField( + model_name='productionline', + name='name', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name='backgroundprocess', + name='created_at', + field=models.DateField(auto_now_add=True), + ), + migrations.AlterField( + model_name='circularityevaluation', + name='report', + field=models.ForeignKey(blank=True, help_text='Report describing the circularity assessment, and manual for monitoring and updating the circularity metrics.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='circularity_eval', to='dpp.document'), + ), + migrations.AlterField( + model_name='process', + name='created_at', + field=models.DateField(auto_now_add=True), + ), + migrations.AlterField( + model_name='productexchange', + name='type', + field=models.CharField(choices=[('prod', 'Component (added to the product)'), ('cons', 'Consumable'), ('ener', 'Electricity or heat'), ('util', 'Utility or equipment'), ('serv', 'Service'), ('pack', 'Packaging'), ('react', 'Reactant'), ('waste', 'Waste')], max_length=5), + ), + migrations.AlterField( + model_name='productionline', + name='operator', + field=models.ForeignKey(default=0, on_delete=models.SET(dpp.models.get_unknown_company), to='dpp.company', verbose_name='Producing company'), + preserve_default=False, + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + migrations.CreateModel( + name='ManufacturingProcess', + fields=[ + ('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.activity')), + ('modified_at', models.DateField(auto_now=True)), + ('functional_flow', models.OneToOneField(help_text='The output product of this manufacturing process.', on_delete=django.db.models.deletion.RESTRICT, related_name='produced_by_other', to='dpp.productmodel', verbose_name='Main product')), + ], + bases=('dpp.activity',), + ), + migrations.AddField( + model_name='backgroundprocess', + name='manufacturingprocess_ptr', + field=models.OneToOneField(auto_created=True, default=0, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.manufacturingprocess'), + preserve_default=False, + ), + migrations.AddField( + model_name='process', + name='production_line', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='bop', to='dpp.productionline'), + ), + migrations.AddField( + model_name='transport', + name='production_line', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='transport', to='dpp.productionline'), + ), + ] diff --git a/mysite/dpp/migrations/0022_alter_document_issue_date_and_more.py b/mysite/dpp/migrations/0022_alter_document_issue_date_and_more.py new file mode 100644 index 0000000..7e04025 --- /dev/null +++ b/mysite/dpp/migrations/0022_alter_document_issue_date_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 5.2.8 on 2026-01-12 12:28 + +import datetime +import django.core.validators +import django.db.models.deletion +import dpp.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0021_rename_upload_date_document_issue_date_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='document', + name='issue_date', + field=models.DateTimeField(default=datetime.date.today), + ), + migrations.AlterField( + model_name='process', + name='production_line', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bop', to='dpp.productionline'), + ), + migrations.AlterField( + model_name='productionline', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + migrations.AlterField( + model_name='transport', + name='production_line', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transport', to='dpp.productionline'), + ), + migrations.CreateModel( + name='DppDetails', + fields=[ + ('product', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='dpp.productmodel')), + ('CPV_code', models.CharField(blank=True, help_text='Common Procurement Vocabulary code', max_length=20)), + ('GS1_GPC_code', models.CharField(blank=True, help_text='Global Product Classification code', max_length=20)), + ('warranty_period', models.DecimalField(decimal_places=1, default=0, help_text='Warranty period in years', max_digits=3, validators=[django.core.validators.MinValueValidator(0)])), + ('spare_parts_availability_duration', models.DecimalField(decimal_places=1, default=0, help_text='Spare parts availability in years', max_digits=3, validators=[django.core.validators.MinValueValidator(0)])), + ('takeback_system', models.CharField(choices=[('no', 'No take-back system'), ('basic', 'Collection on request'), ('active', 'Structured take-back with dedicated channels or collection points'), ('advanced', 'Certified, traceable take-back system')], default='no', max_length=10)), + ('origin', models.ForeignKey(on_delete=models.SET(dpp.models.get_unknown_company), related_name='manufactured_products', to='dpp.company')), + ('quality_compliance_documents', models.ManyToManyField(blank=True, to='dpp.document')), + ('vendor_or_importer', models.ForeignKey(blank=True, null=True, on_delete=models.SET(dpp.models.get_unknown_importer), related_name='sold_products', to='dpp.importer', verbose_name='Responsible Economic Operator')), + ], + options={ + 'verbose_name': 'DPP details', + 'verbose_name_plural': 'DPP details', + }, + ), + ] diff --git a/mysite/dpp/migrations/0023_lifecycleevent_and_more.py b/mysite/dpp/migrations/0023_lifecycleevent_and_more.py new file mode 100644 index 0000000..ef144eb --- /dev/null +++ b/mysite/dpp/migrations/0023_lifecycleevent_and_more.py @@ -0,0 +1,119 @@ +# Generated by Django 5.2.8 on 2026-01-13 14:41 + +import django.db.models.deletion +import dpp.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0022_alter_document_issue_date_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='LifeCycleEvent', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('type', models.CharField(choices=[('sales', 'Sales or ownership transfer'), ('test', 'Inspection'), ('Maintenance', [('corrective', 'Repair'), ('software', 'Software update'), ('performance', 'Performance upgrade'), ('safety', 'Safety improvement'), ('energy_optimization', 'Energy optimization'), ('compliance', 'Compliance update'), ('other', 'Other maintenance')]), ('disassembly', 'Disassembly'), ('Closing the loop', [('R3', 'Reuse'), ('R5', 'Refurbish'), ('R6', 'Remanufacture'), ('R7', 'Repurpose')]), ('End-of-life treatment', [('recycling', 'Recycling'), ('landfill', 'Landfilling'), ('incineration', 'Incineration'), ('stockpiling', 'Stockpiling'), ('disposal', 'Disposal (unspecified)')])], max_length=30)), + ('date', models.DateField(auto_now_add=True)), + ('activity_data', models.ForeignKey(default=dpp.models.LifeCycleEvent.get_empty_activity, help_text='Activity describing the inputs and outputs (optional).', on_delete=django.db.models.deletion.SET_DEFAULT, to='dpp.manufacturingprocess')), + ('maintenance_plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dpp.document')), + ('operator', models.ForeignKey(help_text='Entity that performs this event.', on_delete=django.db.models.deletion.CASCADE, to='dpp.serviceoperator')), + ], + ), + migrations.RemoveField( + model_name='replacedcomponent', + name='new_component', + ), + migrations.RemoveField( + model_name='replacedcomponent', + name='old_component', + ), + migrations.RemoveField( + model_name='replacedcomponent', + name='service_record', + ), + migrations.RemoveField( + model_name='serviceevent', + name='maintenance_plan', + ), + migrations.RemoveField( + model_name='serviceevent', + name='operator', + ), + migrations.RemoveField( + model_name='serviceevent', + name='product', + ), + migrations.RemoveField( + model_name='servicerecord', + name='service_event', + ), + migrations.AddField( + model_name='productitem', + name='circularity', + field=models.CharField(default='new', max_length=50), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + migrations.CreateModel( + name='DisassemblyEvent', + fields=[ + ('lifecycleevent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.lifecycleevent')), + ], + bases=('dpp.lifecycleevent',), + ), + migrations.CreateModel( + name='InspectionEvent', + fields=[ + ('lifecycleevent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.lifecycleevent')), + ('diagnostic_results', models.ManyToManyField(blank=True, to='dpp.document')), + ], + bases=('dpp.lifecycleevent',), + ), + migrations.CreateModel( + name='MaintenanceEvent', + fields=[ + ('lifecycleevent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.lifecycleevent')), + ('description', models.TextField(max_length=300)), + ('affected_functionality', models.CharField(blank=True, max_length=200)), + ('software_or_hardware', models.BooleanField(choices=[(True, 'Software'), (False, 'Hardware')])), + ('root_cause', models.TextField(blank=True, max_length=300)), + ('diagnostics_performed', models.TextField(blank=True, max_length=300)), + ('corrective_action', models.TextField(blank=True, max_length=300)), + ], + bases=('dpp.lifecycleevent',), + ), + migrations.AddField( + model_name='lifecycleevent', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_events', to='dpp.productitem'), + ), + migrations.CreateModel( + name='ItemExchange', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.SmallIntegerField(help_text='Inputs are positive, outputs are negative.')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dpp.lifecycleevent')), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dpp.productitem')), + ], + ), + migrations.DeleteModel( + name='EndOfLife', + ), + migrations.DeleteModel( + name='ReplacedComponent', + ), + migrations.DeleteModel( + name='ServiceEvent', + ), + migrations.DeleteModel( + name='ServiceRecord', + ), + ] diff --git a/mysite/dpp/migrations/0024_remove_lifecycleevent_maintenance_plan_and_more.py b/mysite/dpp/migrations/0024_remove_lifecycleevent_maintenance_plan_and_more.py new file mode 100644 index 0000000..0e82cc0 --- /dev/null +++ b/mysite/dpp/migrations/0024_remove_lifecycleevent_maintenance_plan_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.8 on 2026-01-14 09:01 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0023_lifecycleevent_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='lifecycleevent', + name='maintenance_plan', + ), + migrations.RenameField( + model_name='organization', + old_name='organization_id', + new_name='id', + ), + migrations.AddField( + model_name='maintenanceevent', + name='maintenance_plan', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.RESTRICT, to='dpp.document'), + preserve_default=False, + ), + migrations.AddField( + model_name='organization', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='metadata', + name='registration_number', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + migrations.DeleteModel( + name='CircularityEnabler', + ), + ] diff --git a/mysite/dpp/migrations/0025_manufacturingprocess_facility_id_and_more.py b/mysite/dpp/migrations/0025_manufacturingprocess_facility_id_and_more.py new file mode 100644 index 0000000..fe9672c --- /dev/null +++ b/mysite/dpp/migrations/0025_manufacturingprocess_facility_id_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.8 on 2026-01-16 13:38 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0024_remove_lifecycleevent_maintenance_plan_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='manufacturingprocess', + name='facility_id', + field=models.CharField(default='unknown', max_length=50, verbose_name='Unique facility identifier'), + preserve_default=False, + ), + migrations.AddField( + model_name='productionline', + name='facility_id', + field=models.CharField(default='unknown', max_length=50, verbose_name='Unique facility identifier'), + preserve_default=False, + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + migrations.CreateModel( + name='Component', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.PositiveSmallIntegerField()), + ('component', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='part_of', to='dpp.productmodel')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='composed_of', to='dpp.productmodel')), + ], + options={ + 'verbose_name': 'Replaceable component', + 'ordering': ['product', 'component'], + 'unique_together': {('product', 'component')}, + }, + ), + migrations.CreateModel( + name='Concentration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('fraction', models.FloatField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1)])), + ('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='concentration_in', to='dpp.material')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='concentration', to='dpp.productmodel')), + ], + options={ + 'ordering': ['product', 'material'], + 'unique_together': {('product', 'material')}, + }, + ), + ] diff --git a/mysite/dpp/migrations/0026_remove_facility_id_and_more.py b/mysite/dpp/migrations/0026_remove_facility_id_and_more.py new file mode 100644 index 0000000..8f0eac7 --- /dev/null +++ b/mysite/dpp/migrations/0026_remove_facility_id_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.8 on 2026-01-19 11:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0025_manufacturingprocess_facility_id_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='manufacturingprocess', + old_name='facility_id', + new_name='facility', + ), + migrations.RenameField( + model_name='productionline', + old_name='facility_id', + new_name='facility', + ), + migrations.AddField( + model_name='activity', + name='facility_id', + field=models.CharField(default='unknown', max_length=50, verbose_name='Unique facility identifier'), + preserve_default=False, + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default='', max_length=50), + ), + migrations.AlterUniqueTogether( + name='envexchange', + unique_together={('process', 'substance', 'compartment', 'direction')}, + ), + migrations.RemoveField( + model_name='activity', + name='facility_id', + ), + ] diff --git a/mysite/dpp/migrations/0027_introduce_flow.py b/mysite/dpp/migrations/0027_introduce_flow.py new file mode 100644 index 0000000..dd408a2 --- /dev/null +++ b/mysite/dpp/migrations/0027_introduce_flow.py @@ -0,0 +1,161 @@ +# Generated by Django 5.2.8 on 2026-01-26 09:21 + +import django.db.models.deletion +import dpp.models +from django.db import migrations, models + + +def copy_id_to_flow_ptr(apps, schema_editor): + """Function to migrate ProductModel.id to new Flow model""" + # Get the ProductModel and Flow model classes + ProductModel = apps.get_model('dpp', 'ProductModel') + Flow = apps.get_model('dpp', 'Flow') + + for product in ProductModel.objects.all(): + # Create a Flow instance as parent + flow_instance = Flow.objects.create(id=product.id) + # Link the new flow to the product + product.flow_ptr = flow_instance + product.save(update_fields=["flow_ptr"]) + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0026_remove_facility_id_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Flow', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), + migrations.AddField( + model_name='productmodel', + name='flow_ptr', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, null=True, to='dpp.flow'), + preserve_default=False, + ), + migrations.RunPython(copy_id_to_flow_ptr), + migrations.AlterField( + model_name='productmodel', + name='flow_ptr', + field=models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.flow'), + preserve_default=False, + ), + migrations.DeleteModel(name='productbatch'), + migrations.DeleteModel(name='secondaryproduct'), + migrations.RemoveField(model_name='productitem', name='product_batch'), + migrations.AlterField( + model_name='envexchange', + name='compartment', + field=models.CharField(choices=[('Air', [('air-urban', 'Urban air'), ('air-rural', 'Non-urban air or from high stacks'), ('air-lt', 'Long-term'), ('air-indoor', 'Indoor'), ('air-strato', '10-30 km above ground'), ('air', 'Unspecified')]), ('uptake', 'Direct human uptake'), ('Soil', [('soil-agri', 'Agricultural'), ('soil-forest', 'Forest'), ('soil-indu', 'Industrial'), ('soil', 'Unspecified')]), ('Water', [('surface_water', 'Surface water'), ('seawater', 'Seawater'), ('groundwater', 'Groundwater'), ('groundwater-lt', 'Groundwater, long term'), ('groundwater-deep', 'Deep underground wells'), ('water', 'Unspecified')])], max_length=20), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='temporal_scope', + field=models.CharField(default=dpp.models.SustainabilityEvaluation.get_year, max_length=50), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dpp.flow'), + ), + migrations.AlterField( + model_name='circularityevaluation', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dpp.flow'), + ), + migrations.AlterField( + model_name='component', + name='component', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='part_of', to='dpp.flow'), + ), + migrations.AlterField( + model_name='component', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='composed_of', to='dpp.flow'), + ), + migrations.AlterField( + model_name='composition', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='composition', to='dpp.flow'), + ), + migrations.AlterField( + model_name='concentration', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='concentration', to='dpp.flow'), + ), + migrations.AlterField( + model_name='manufacturingprocess', + name='functional_flow', + field=models.OneToOneField(help_text='The output product of this manufacturing process.', on_delete=django.db.models.deletion.RESTRICT, related_name='produced_by_other', to='dpp.flow', verbose_name='Main product'), + ), + migrations.AlterField( + model_name='process', + name='functional_flow', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='produced_by', to='dpp.flow', verbose_name='Main output'), + ), + migrations.AlterField( + model_name='productexchange', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exchanged_by', to='dpp.flow'), + ), + migrations.AlterField( + model_name='productionline', + name='final_product', + field=models.OneToOneField(help_text='The output product of this production line', on_delete=django.db.models.deletion.RESTRICT, to='dpp.flow', verbose_name='Final product'), + ), + migrations.AlterField( + model_name='transport', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transport', to='dpp.flow'), + ), + migrations.AlterField( + model_name='alias', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alias', to='dpp.flow'), + ), + migrations.AlterField( + model_name='dppdetails', + name='product', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='dpp.flow'), + ), + migrations.AlterField( + model_name='productproperties', + name='product', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='properties', to='dpp.flow'), + ), + migrations.RemoveField( + model_name='productmodel', + name='id', + ), + migrations.CreateModel( + name='SecondaryProduct', + fields=[ + ('productmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.productmodel')), + ('circularity', models.CharField(choices=[('R3', 'reused'), ('R5', 'refurbished'), ('R6', 'remanufactured'), ('R7', 'repurposed'), ('R8', 'recycled'), ('in', 'incinerated'), ('lf', 'landfilled'), ('-', 'unknown')], default='-', max_length=2)), + ('is_waste', models.BooleanField(default=False)), + ], + bases=('dpp.productmodel',), + ), + migrations.CreateModel( + name='ProductBatch', + fields=[ + ('flow_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.flow')), + ('batch_number', models.PositiveIntegerField()), + ('model', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='batch', to='dpp.flow')), + ], + options={ + 'verbose_name_plural': 'Product batches', + }, + bases=('dpp.flow',), + ), + migrations.AddField( + model_name='productitem', + name='product_batch', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='dpp.productbatch'), + preserve_default=False, + ), + ] diff --git a/mysite/dpp/migrations/0028_introduce_facility_and_more.py b/mysite/dpp/migrations/0028_introduce_facility_and_more.py new file mode 100644 index 0000000..6004593 --- /dev/null +++ b/mysite/dpp/migrations/0028_introduce_facility_and_more.py @@ -0,0 +1,125 @@ +# Generated by Django 5.2.8 on 2026-01-27 14:45 + +import django.db.models.deletion +import django_countries.fields +import uuid +import dpp.models +from django.db import migrations, models + + +def activity_operator_to_facility(apps, schema_editor): + """Function to migrate operator to facility""" + Activity = apps.get_model('dpp', 'Activity') + Facility = apps.get_model('dpp', 'Facility') + + for activity in Activity.objects.all(): + operator = activity.operator + if not operator: + print(f"{activity} has no operator.") + return + # Create a Facility instance + facility = Facility.objects.create( + operator=operator, country=operator.country, address=operator.address + ) + activity.facility_id = facility.pk + activity.save() + +def production_operator_to_facility(apps, schema_editor): + """Function to migrate operator to facility""" + ProductionLine = apps.get_model('dpp', 'ProductionLine') + Facility = apps.get_model('dpp', 'Facility') + + for activity in ProductionLine.objects.all(): + operator = activity.operator + # Create a Facility instance + facility = Facility.objects.create( + operator=operator, country=operator.country, address=operator.address + ) + activity.facility_id = facility.pk + activity.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0027_introduce_flow'), + ] + + operations = [ + # Remove, because the date is already specified in CircularityEvaluation + migrations.RemoveField( + model_name='circularityscore', + name='modified_at', + ), + # Rename 'vendor_or_importer' to 'importer' + migrations.RemoveField( + model_name='dppdetails', + name='vendor_or_importer', + ), + migrations.AddField( + model_name='dppdetails', + name='importer', + field=models.ForeignKey(blank=True, help_text='Specify if the product is imported from outside the EU.', null=True, on_delete=models.SET(dpp.models.get_unknown_importer), related_name='imported_products', to='dpp.importer'), + ), + # Issuer should actually be linked to a verified ID, but we keep it here for now + migrations.AddField( + model_name='metadata', + name='issuer', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='dpp.institution'), + preserve_default=False, + ), + # Generalized relation: any Activity can exchange (was Process) + migrations.AlterField( + model_name='envexchange', + name='process', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='env_exchanges', to='dpp.activity'), + ), + # Add REO as field to DPP details (linked to item) + migrations.AddField( + model_name='metadata', + name='reo', + field=models.ForeignKey(default=1, help_text='The entity bearing legal responsibility for the DPP and the product.', on_delete=django.db.models.deletion.PROTECT, to='dpp.company', verbose_name='Responsible economic operator'), + preserve_default=False, + ), + # Create Facility class, refer to it for location/operator + migrations.CreateModel( + name='Facility', + fields=[ + ('uid', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unique facility identifier', primary_key=True, serialize=False)), + ('country', django_countries.fields.CountryField(max_length=2)), + ('address', models.TextField(blank=True, max_length=100)), + ('operator', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='dpp.company')), + ], + ), + migrations.RemoveField( + model_name='manufacturingprocess', + name='facility', + ), + migrations.AddField( + model_name='activity', + name='facility', + field=models.ForeignKey(blank=True, help_text='Production location', null=True, on_delete=django.db.models.deletion.CASCADE, to='dpp.facility'), + ), + migrations.RunPython(activity_operator_to_facility), + migrations.RemoveField( + model_name='activity', + name='location', + ), + migrations.RemoveField( + model_name='activity', + name='operator', + ), + migrations.RemoveField( + model_name='productionline', + name='facility', + ), + migrations.AddField( + model_name='productionline', + name='facility', + field=models.ForeignKey(blank=True, help_text='Production location', null=True, on_delete=django.db.models.deletion.CASCADE, to='dpp.facility'), + ), + migrations.RunPython(production_operator_to_facility), + migrations.RemoveField( + model_name='productionline', + name='operator', + ), + ] diff --git a/mysite/dpp/migrations/0029_alter_facility_address.py b/mysite/dpp/migrations/0029_alter_facility_address.py new file mode 100644 index 0000000..69ff3d2 --- /dev/null +++ b/mysite/dpp/migrations/0029_alter_facility_address.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.8 on 2026-01-28 12:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0028_introduce_facility_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='facility', + name='address', + field=models.TextField(max_length=100), + ), + migrations.AlterModelOptions( + name='facility', + options={'verbose_name_plural': 'Facilities'}, + ), + ] diff --git a/mysite/dpp/migrations/0030_alter_facility_options_remove_company_country_and_more.py b/mysite/dpp/migrations/0030_alter_facility_options_remove_company_country_and_more.py new file mode 100644 index 0000000..ddbd98e --- /dev/null +++ b/mysite/dpp/migrations/0030_alter_facility_options_remove_company_country_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.8 on 2026-02-03 11:16 + +import django.db.models.deletion +import django_countries.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0029_alter_facility_address'), + ] + + operations = [ + migrations.AlterModelOptions( + name='facility', + options={'ordering': ['operator', 'country', 'address'], 'verbose_name_plural': 'Facilities'}, + ), + migrations.RemoveField( + model_name='company', + name='country', + ), + migrations.RemoveField( + model_name='material', + name='reused_fraction', + ), + migrations.AddField( + model_name='material', + name='chemical_formula', + field=models.CharField(blank=True, max_length=30), + ), + migrations.AddField( + model_name='organization', + name='country', + field=django_countries.fields.CountryField(default='JP', max_length=2), + preserve_default=False, + ), + migrations.AlterField( + model_name='organization', + name='address', + field=models.TextField(blank=True, help_text='Location of the headquarters, or correspondence address', max_length=100), + ), + migrations.AlterField( + model_name='productitem', + name='DPP_metadata', + field=models.OneToOneField(blank=True, on_delete=django.db.models.deletion.PROTECT, to='dpp.metadata'), + ), + migrations.AlterUniqueTogether( + name='facility', + unique_together={('country', 'address', 'operator')}, + ), + ] diff --git a/mysite/dpp/migrations/0031_alter_productexchange_type_publisher.py b/mysite/dpp/migrations/0031_alter_productexchange_type_publisher.py new file mode 100644 index 0000000..caf7289 --- /dev/null +++ b/mysite/dpp/migrations/0031_alter_productexchange_type_publisher.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.8 on 2026-02-05 11:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0030_alter_facility_options_remove_company_country_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='productexchange', + name='type', + field=models.CharField(choices=[('prod', 'Component (part of the product)'), ('cons', 'Consumable'), ('ener', 'Electricity or heat'), ('util', 'Utility or equipment'), ('serv', 'Service'), ('pack', 'Packaging'), ('react', 'Reactant'), ('waste', 'Waste')], max_length=5), + ), + migrations.CreateModel( + name='Publisher', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.PositiveSmallIntegerField(default=0, help_text='Highest successfully completed step (1-5)')), + ('last_run', models.DateTimeField(auto_now=True)), + ('error_message', models.TextField(blank=True)), + ('production_line', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='dpp.productionline')), + ], + ), + ] diff --git a/mysite/dpp/migrations/0032_repair_backgroundprocess.py b/mysite/dpp/migrations/0032_repair_backgroundprocess.py new file mode 100644 index 0000000..27e93f4 --- /dev/null +++ b/mysite/dpp/migrations/0032_repair_backgroundprocess.py @@ -0,0 +1,33 @@ +# Generated by Sander on 2026-02-05 14:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0031_alter_productexchange_type_publisher'), + ] + + operations = [ + migrations.DeleteModel( + name='backgroundprocess', + ), + migrations.CreateModel( + name='BackgroundProcess', + fields=[ + ('manufacturingprocess_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='dpp.manufacturingprocess')), + ('created_at', models.DateField(auto_now_add=True)), + ('database', models.CharField(blank=True, max_length=50)), + ('db_code', models.CharField(blank=True, help_text='Unique ID in the source database', max_length=50)), + ('tags', models.CharField(blank=True, max_length=150)), + ('type', models.CharField(blank=True, max_length=50)), + ], + options={ + 'verbose_name': 'Average market process', + 'verbose_name_plural': 'Average market processes', + }, + bases=('dpp.manufacturingprocess',), + ), + ] diff --git a/mysite/dpp/migrations/0033_delete_sharedprocess_add_related_names_more.py b/mysite/dpp/migrations/0033_delete_sharedprocess_add_related_names_more.py new file mode 100644 index 0000000..b705c5a --- /dev/null +++ b/mysite/dpp/migrations/0033_delete_sharedprocess_add_related_names_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.8 on 2026-02-19 14:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0032_repair_backgroundprocess'), + ] + + operations = [ + migrations.DeleteModel( + name='SharedProcess', + ), + migrations.RemoveField( + model_name='dppdetails', + name='origin', + ), + migrations.AlterField( + model_name='dppdetails', + name='product', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='details', serialize=False, to='dpp.flow'), + ), + migrations.AlterField( + model_name='itemexchange', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_exchanges', to='dpp.lifecycleevent'), + ), + migrations.AlterField( + model_name='sustainabilityevaluation', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sustainability_evaluation', to='dpp.flow'), + ), + ] diff --git a/mysite/dpp/migrations/0034_alter_circularityevaluation_product_and_more.py b/mysite/dpp/migrations/0034_alter_circularityevaluation_product_and_more.py new file mode 100644 index 0000000..a61835b --- /dev/null +++ b/mysite/dpp/migrations/0034_alter_circularityevaluation_product_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.8 on 2026-02-20 14:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0033_delete_sharedprocess_add_related_names_more'), + ] + + operations = [ + migrations.AlterField( + model_name='circularityevaluation', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='circularity_evaluation', to='dpp.flow'), + ), + migrations.AlterField( + model_name='document', + name='type', + field=models.CharField(choices=[('Technical document', [('technical_drawing', 'Technical drawing'), ('safety_sheet', 'Safety sheet'), ('conformity_certificate', 'Conformity certificate'), ('mass_balance', 'Mass balance'), ('energy_balance', 'Energy balance'), ('other', 'Other')]), ('Compliance document', [('compliance', 'Compliance report'), ('quality_cert', 'Quality certificate'), ('safety_data', 'Safety data sheet'), ('legal', 'Legal document'), ('labor', 'Labor compliance'), ('qms', 'Quality Management System certificate'), ('warranty', 'Warranty information'), ('spare_parts', 'Spare parts availability'), ('takeback', 'Return and take-back')]), ('Manuals', [('manual', 'User manual'), ('maintenance', 'Maintenance manual'), ('installation', 'Installation guide'), ('eol', 'End-of-life guidelines'), ('datasheet', 'Product data sheet')]), ('Labels', [('label', 'Voluntary label'), ('energy_label', 'Energy label'), ('ecolabel', 'Ecolabel'), ('recycling_label', 'Recycling label'), ('legal', 'Legal markings')])], default='other', max_length=25, verbose_name='Document type'), + ), + migrations.AlterField( + model_name='productitem', + name='DPP_metadata', + field=models.OneToOneField(blank=True, on_delete=django.db.models.deletion.PROTECT, related_name='product_item', to='dpp.metadata'), + ), + ] diff --git a/mysite/dpp/migrations/0035_rename_quality_compliance_documents_and_more.py b/mysite/dpp/migrations/0035_rename_quality_compliance_documents_and_more.py new file mode 100644 index 0000000..78cd3dd --- /dev/null +++ b/mysite/dpp/migrations/0035_rename_quality_compliance_documents_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.8 on 2026-02-24 13:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0034_alter_circularityevaluation_product_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='dppdetails', + old_name='quality_compliance_documents', + new_name='compliance_documents', + ), + migrations.RemoveField( + model_name='productitem', + name='DPP_metadata', + ), + migrations.AddField( + model_name='metadata', + name='product_item', + field=models.OneToOneField(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='dpp_metadata', to='dpp.productitem'), + preserve_default=False, + ), + ] diff --git a/mysite/dpp/migrations/0036_move_gtin_code_and_more.py b/mysite/dpp/migrations/0036_move_gtin_code_and_more.py new file mode 100644 index 0000000..a481e84 --- /dev/null +++ b/mysite/dpp/migrations/0036_move_gtin_code_and_more.py @@ -0,0 +1,108 @@ +# Generated by Django 5.2.8 on 2026-03-05 11:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0035_rename_quality_compliance_documents_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='productitem', + name='GTIN_code', + ), + migrations.AddField( + model_name='productbatch', + name='GTIN_code', + field=models.CharField(default='8712345678901', help_text='Global Trade Item Number (or EAN)', max_length=13, unique=True), + preserve_default=False, + ), + migrations.AddField( + model_name='publisher', + name='access_link_base', + field=models.URLField(blank=True, help_text='Base URL to DPP record.'), + ), + migrations.AddField( + model_name='publisher', + name='access_log_enabled', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='publisher', + name='access_policy', + field=models.URLField(blank=True, help_text='URL to data access terms and conditions.'), + ), + migrations.AddField( + model_name='publisher', + name='amount', + field=models.PositiveSmallIntegerField(default=2, help_text='How many items need a DPP.'), + preserve_default=False, + ), + migrations.AddField( + model_name='publisher', + name='audit_trail_mechanism', + field=models.SmallIntegerField(choices=[(0, 'None'), (1, 'Log files'), (2, 'Immutable ledger')], default=0), + ), + migrations.AddField( + model_name='publisher', + name='credential_format', + field=models.CharField(choices=[('json_ld', 'JSON-LD'), ('verifialble', 'Verifiable credential'), ('xml', 'XML'), ('other', 'Other')], default='xml', max_length=50), + preserve_default=False, + ), + migrations.AddField( + model_name='publisher', + name='issuer', + field=models.ForeignKey(default=10, on_delete=django.db.models.deletion.PROTECT, to='dpp.institution'), + preserve_default=False, + ), + migrations.AddField( + model_name='publisher', + name='language', + field=models.CharField(default='EN', help_text='Language used in descriptions', max_length=20), + ), + migrations.AddField( + model_name='publisher', + name='registration_numbers', + field=models.CharField(blank=True, help_text='Range of numbers (comma-separated)', max_length=500), + ), + migrations.AddField( + model_name='publisher', + name='reo', + field=models.ForeignKey(default=4, help_text='The entity bearing legal responsibility for the DPP and the product.', on_delete=django.db.models.deletion.PROTECT, to='dpp.company', verbose_name='Responsible economic operator'), + preserve_default=False, + ), + migrations.AddField( + model_name='publisher', + name='storage_location', + field=models.SmallIntegerField(choices=[(0, 'Undeclared'), (1, 'On-premise server'), (2, 'Commercial cloud server'), (3, 'Centralized certified server'), (4, 'Decentralized storage')], default=0), + ), + migrations.AddField( + model_name='publisher', + name='update_interval', + field=models.CharField(choices=[('-', 'never'), ('W', 'weekly'), ('M', 'monthly'), ('Q', 'quarterly'), ('A', 'annually'), ('E', 'event_driven')], default='-', max_length=2), + ), + migrations.AddField( + model_name='publisher', + name='verification_type', + field=models.SmallIntegerField(choices=[(0, 'None'), (1, 'Digital signature'), (2, 'Third party'), (3, 'Blockchain')], default=0), + ), + migrations.AddField( + model_name='publisher', + name='version', + field=models.CharField(default='1.0', max_length=10), + ), + migrations.AlterField( + model_name='metadata', + name='version', + field=models.CharField(max_length=10), + ), + migrations.AlterField( + model_name='sustainabilityscore', + name='evaluation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sustainability_score', to='dpp.sustainabilityevaluation'), + ), + ] diff --git a/mysite/dpp/migrations/0037_rename_uid_facility_uuid_and_more.py b/mysite/dpp/migrations/0037_rename_uid_facility_uuid_and_more.py new file mode 100644 index 0000000..f346746 --- /dev/null +++ b/mysite/dpp/migrations/0037_rename_uid_facility_uuid_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 5.2.8 on 2026-03-13 13:43 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0036_move_gtin_code_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='emission', + options={'ordering': ['name']}, + ), + migrations.RenameField( + model_name='facility', + old_name='uid', + new_name='uuid', + ), + migrations.RenameField( + model_name='productbatch', + old_name='GTIN_code', + new_name='GTIN', + ), + migrations.RemoveField( + model_name='productmodel', + name='unit_price', + ), + migrations.AddField( + model_name='envexchange', + name='comment', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='manufacturingprocess', + name='energy_balance', + field=models.ForeignKey(blank=True, help_text='A document showing all energy flows exchanges of the process.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='energy_balance', to='dpp.document'), + ), + migrations.AddField( + model_name='manufacturingprocess', + name='mass_balance', + field=models.ForeignKey(blank=True, help_text='A document showing all material exchanges of the process.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mass_balance', to='dpp.document'), + ), + migrations.AddField( + model_name='productexchange', + name='comment', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='transport', + name='utilisation_ratio', + field=models.FloatField(default=0.5, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1)]), + ), + migrations.AlterField( + model_name='document', + name='type', + field=models.CharField(choices=[('Technical document', [('technical_drawing', 'Technical drawing'), ('safety_sheet', 'Safety sheet'), ('conformity_certificate', 'Conformity certificate'), ('mass_balance', 'Mass balance'), ('energy_balance', 'Energy balance'), ('other', 'Other')]), ('Compliance document', [('compliance', 'Compliance report'), ('quality_cert', 'Quality certificate'), ('safety_data', 'Safety data sheet'), ('legal', 'Legal document'), ('labor', 'Labor compliance'), ('qms', 'Quality Management System certificate'), ('warranty', 'Warranty information'), ('spare_parts', 'Spare parts availability'), ('takeback', 'Return and take-back')]), ('Manuals', [('manual', 'User manual'), ('maintenance', 'Maintenance manual'), ('installation', 'Installation guide'), ('eol', 'End-of-life guidelines'), ('datasheet', 'Product data sheet')]), ('Labels', [('label', 'Voluntary label'), ('energy_label', 'Energy label'), ('ecolabel', 'Ecolabel'), ('circularity_label', 'Circularity label'), ('legal', 'Legal markings')])], default='other', max_length=25, verbose_name='Document type'), + ), + migrations.AlterField( + model_name='dppdetails', + name='CPV_code', + field=models.CharField(blank=True, help_text='Common Procurement Vocabulary code', max_length=8), + ), + migrations.AlterField( + model_name='dppdetails', + name='GS1_GPC_code', + field=models.CharField(blank=True, help_text='Global Product Classification code', max_length=9), + ), + migrations.AlterField( + model_name='emission', + name='name', + field=models.CharField(max_length=70, unique=True), + ), + migrations.AlterField( + model_name='hazardousmaterial', + name='CAS_number', + field=models.CharField(max_length=50, unique=True), + ), + migrations.AlterField( + model_name='importer', + name='EORI_number', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='productionline', + name='energy_balance', + field=models.ForeignKey(blank=True, help_text='Add a document showing all energy flows going in and out of the production line. (Optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pl_energy_balance', to='dpp.document'), + ), + migrations.AlterField( + model_name='productionline', + name='mass_balance', + field=models.ForeignKey(blank=True, help_text='Add a document showing all material flows going in and out of the production line. (Optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pl_mass_balance', to='dpp.document'), + ), + migrations.AlterField( + model_name='publisher', + name='error_message', + field=models.TextField(blank=True, max_length=200), + ), + ] diff --git a/mysite/dpp/migrations/0038_rename_impact_category_sustainabilityscore_impact_indicator_and_more.py b/mysite/dpp/migrations/0038_rename_impact_category_sustainabilityscore_impact_indicator_and_more.py new file mode 100644 index 0000000..ee55b1c --- /dev/null +++ b/mysite/dpp/migrations/0038_rename_impact_category_sustainabilityscore_impact_indicator_and_more.py @@ -0,0 +1,120 @@ +# Generated by Django 5.2.8 on 2026-03-25 08:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0037_rename_uid_facility_uuid_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='sustainabilityscore', + old_name='impact_category', + new_name='impact_indicator', + ), + migrations.RemoveField( + model_name='envexchange', + name='shape', + ), + migrations.RemoveField( + model_name='maintenanceevent', + name='corrective_action', + ), + migrations.RemoveField( + model_name='maintenanceevent', + name='diagnostics_performed', + ), + migrations.RemoveField( + model_name='productexchange', + name='shape', + ), + migrations.AddField( + model_name='inspectionevent', + name='description', + field=models.TextField(blank=True, help_text='Diagnostic tools or methods used', max_length=300), + ), + migrations.AddField( + model_name='sustainabilityevaluation', + name='is_environmental', + field=models.BooleanField(default=True, verbose_name='Whether this is an environmental sustainability evaluation (LCA).'), + ), + migrations.AlterField( + model_name='document', + name='type', + field=models.CharField(choices=[('other', 'Other'), ('Technical document', [('technical_drawing', 'Technical drawing'), ('safety_sheet', 'Safety sheet'), ('conformity_certificate', 'Conformity certificate'), ('mass_balance', 'Mass balance'), ('energy_balance', 'Energy balance'), ('datasheet', 'Product data sheet')]), ('Compliance document', [('compliance', 'Compliance report'), ('quality_cert', 'Quality certificate'), ('safety_data', 'Safety data sheet'), ('legal', 'Legal document'), ('labor', 'Labor compliance'), ('qms', 'Quality Management System certificate'), ('warranty', 'Warranty information'), ('spare_parts', 'Spare parts availability'), ('takeback', 'Return and take-back')]), ('Manual', [('manual', 'User manual'), ('maintenance', 'Maintenance manual'), ('installation', 'Installation guide'), ('eol', 'End-of-life guidelines')]), ('Label', [('label', 'Voluntary label'), ('energy_label', 'Energy label'), ('ecolabel', 'Ecolabel'), ('circularity_label', 'Circularity label'), ('legal', 'Legal markings')])], default='other', max_length=25, verbose_name='Document type'), + ), + migrations.AlterField( + model_name='dppdetails', + name='takeback_system', + field=models.CharField(choices=[('no', 'No take-back system'), ('basic', 'Collection on request'), ('active', 'Structured take-back with dedicated channels or collection points'), ('advanced', 'Certified, traceable take-back system')], default='no', max_length=10, verbose_name='Take-back system'), + ), + migrations.AlterField( + model_name='envexchange', + name='loc', + field=models.FloatField(blank=True, help_text='for (log)normal and triangular distribution', null=True, verbose_name='Mean or mode'), + ), + migrations.AlterField( + model_name='envexchange', + name='scale', + field=models.FloatField(blank=True, help_text='or log-space standard deviation', null=True, verbose_name='Standard deviation'), + ), + migrations.AlterField( + model_name='maintenanceevent', + name='root_cause', + field=models.TextField(blank=True, help_text='For repair only. Specify the root cause of failure.', max_length=300), + ), + migrations.AlterField( + model_name='manufacturingprocess', + name='functional_flow', + field=models.OneToOneField(help_text='The output product of this manufacturing process.', on_delete=django.db.models.deletion.RESTRICT, related_name='manufacturing_information', to='dpp.flow', verbose_name='Main product'), + ), + migrations.AlterField( + model_name='metadata', + name='issuer', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dpp.organization'), + ), + migrations.AlterField( + model_name='metadata', + name='reo', + field=models.ForeignKey(help_text='The entity bearing legal responsibility for the DPP and the product.', on_delete=django.db.models.deletion.PROTECT, related_name='reo_of', to='dpp.company', verbose_name='Responsible economic operator'), + ), + migrations.AlterField( + model_name='productexchange', + name='loc', + field=models.FloatField(blank=True, help_text='for (log)normal and triangular distribution', null=True, verbose_name='Mean or mode'), + ), + migrations.AlterField( + model_name='productexchange', + name='scale', + field=models.FloatField(blank=True, help_text='or log-space standard deviation', null=True, verbose_name='Standard deviation'), + ), + migrations.AlterField( + model_name='publisher', + name='error_message', + field=models.TextField(blank=True, editable=False, max_length=200), + ), + migrations.AlterField( + model_name='publisher', + name='issuer', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dpp.organization'), + ), + migrations.AlterField( + model_name='publisher', + name='production_line', + field=models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='dpp.productionline'), + ), + migrations.AlterField( + model_name='publisher', + name='reo', + field=models.ForeignKey(help_text='The entity bearing legal responsibility for the DPP and the product.', on_delete=django.db.models.deletion.PROTECT, related_name='responsible_for', to='dpp.company', verbose_name='Responsible economic operator'), + ), + migrations.AlterField( + model_name='publisher', + name='status', + field=models.PositiveSmallIntegerField(default=0, editable=False, help_text='Highest successfully completed step (1-5)'), + ), + ] diff --git a/mysite/dpp/migrations/0039_alter_instruction_label_and_more.py b/mysite/dpp/migrations/0039_alter_instruction_label_and_more.py new file mode 100644 index 0000000..54a713d --- /dev/null +++ b/mysite/dpp/migrations/0039_alter_instruction_label_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.8 on 2026-04-24 10:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0038_rename_impact_category_sustainabilityscore_impact_indicator_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='instruction', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='instruction', + name='label', + field=models.CharField(max_length=20, unique=True), + ), + migrations.AlterField( + model_name='manufacturingprocess', + name='functional_flow', + field=models.OneToOneField(help_text='The output product of this manufacturing process.', on_delete=django.db.models.deletion.RESTRICT, related_name='manufacturing_info', to='dpp.flow', verbose_name='Main product'), + ), + ] diff --git a/mysite/dpp/migrations/0040_productionline_created_by_user.py b/mysite/dpp/migrations/0040_productionline_created_by_user.py new file mode 100644 index 0000000..1217dcd --- /dev/null +++ b/mysite/dpp/migrations/0040_productionline_created_by_user.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.5 on 2026-06-17 11:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0039_alter_instruction_label_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='productionline', + name='created_by', + field=models.ForeignKey(default=2, on_delete=django.db.models.deletion.CASCADE, related_name='production_lines', to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AlterField( + model_name='productbatch', + name='model', + field=models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='batch', to='dpp.productmodel'), + ), + migrations.AlterUniqueTogether( + name='transport', + unique_together={('production_line', 'product')}, + ), + ] diff --git a/mysite/dpp/models.py b/mysite/dpp/models.py index a7ff666..2b0aa2f 100644 --- a/mysite/dpp/models.py +++ b/mysite/dpp/models.py @@ -1,24 +1,74 @@ -from django.db import models -from django.contrib.contenttypes.models import ContentType +from collections import defaultdict +import datetime +import numpy as np +import pandas as pd +from uuid import uuid4 + +from django.db import models, transaction +from django.db.models.signals import m2m_changed +from django.dispatch import receiver +from django.contrib.auth.models import User from django.core.exceptions import ValidationError -from django.core.validators import MinValueValidator, MaxValueValidator +from django.core.validators import MinValueValidator, MaxValueValidator, FileExtensionValidator from django_countries.fields import CountryField -import datetime +from . import lca FRACTION_VALIDATOR = [MinValueValidator(0), MaxValueValidator(1)] +UNIT_CHOICES = { + 'pcs': 'pieces', + 'Mass': { + 'kg': 'kg', + 'g': 'g', + 'lb': 'lb', + 'oz': 'oz', + }, + 'Volume': { + 'l': 'liters', + 'cm3': 'cm3', + 'dm3': 'dm3', + 'm3': 'm3 (cubic meters)', + 'ft3': 'ft3 (cubic feet)', + 'gal': 'gallons' + }, + 'Energy': { + 'kWh': 'kWh', + 'MWh': 'MWh', + 'MJ': 'MJ', + 'GJ': 'GJ', + }, + 'Transport': { + 'tkm': 'ton.km', + 'm3km': 'm3.km', + } +} +CONVERSIONS = { + 'kg': 1, + 'g': 0.001, + 'lb': 0.4535924, + 'oz': 0.02834952, + 'l': 1, + 'cm3': 0.001, + 'dm3': 1, + 'm3': 1000, + 'ft3': 28.3168466, + 'gal': 3.785412, + 'kWh': 1, + 'MWh': 1000, + 'MJ': 1 / 3.6, + 'GJ': 1000 / 3.6, +} + ## Organizations and companies class Organization(models.Model): - organization_id = models.AutoField(primary_key=True) + # id = models.UUIDField(primary_key=True, default=uuid4, editable=False) # Using default id for simplicity name = models.CharField(max_length=100) - address = models.TextField(max_length=100, blank=True) + address = models.TextField(max_length=100, blank=True, help_text="Location of the headquarters, or correspondence address") + country = CountryField() contact_email = models.EmailField(blank=True) website = models.URLField(blank=True) - legal_documents = models.ForeignKey('Document', blank=True, null=True, on_delete=models.SET_NULL, related_name='organization_legal_documents') - - # class Meta: # If made abstract, cannot link legal_documents here - # abstract = True + legal_documents = models.ForeignKey('Document', blank=True, null=True, on_delete=models.SET_NULL, related_name='organization_legal_documents', help_text="Add official legal documentation associated with the company. This may include licenses, registration papers, permits, or other legally mandated certificates.") def __str__(self): return self.name @@ -27,11 +77,13 @@ class Institution(Organization): type = models.CharField(max_length=30, choices={'university': 'University', 'research': 'Research institute', 'governmental': 'Government agency', 'ngo': 'Non-governmental organization', 'statistical': 'Statistical office', 'accountant': 'Accountant office', 'legal': 'Legal institution', 'other': 'Other'}) class Company(Organization): - vat_number = models.CharField(max_length=50, unique=True, blank=True) - country = CountryField() #NOTE: address inherited from Organization + vat_number = models.CharField("VAT number", max_length=50, blank=True) + + class Meta: + verbose_name_plural = "Companies" class Importer(Company): - EORI_number = models.CharField(max_length=100, blank=True) + EORI_number = models.CharField(max_length=100) class ServiceOperator(Company): service_description = models.CharField(max_length=100) @@ -51,174 +103,323 @@ def get_unknown_servicer(): ) return unknown -class Metadata(models.Model): #FIXME: Should each product have unique metadata? - registration_number = models.UUIDField(primary_key=True, editable=False) - issuer = models.ForeignKey(Institution, on_delete=models.PROTECT) - creation_date = models.DateField(auto_now_add=True) - last_modified = models.DateField(auto_now=True) - version = models.CharField(max_length=20) - # Data access & governance - access_link = models.URLField(blank=True) - access_policy = models.URLField(blank=True) - access_log_enabled = models.BooleanField(default=True) - verification_type = models.SmallIntegerField(choices={0: 'None', 1: 'Digital signature', 2: 'Third party', 3: 'Blockchain'}, default=0) - credential_format = models.CharField(max_length=50, choices={'json_ld': 'JSON-LD', 'verifialble':'Verifiable credential', 'xml': 'XML', 'other': 'Other'}) - storage_location = models.SmallIntegerField(choices={0: 'Undeclared', 1: 'On-premise server', 2: 'Commercial cloud server', 3: 'Centralized certified server', 4: 'Decentralized storage'}) - audit_trail_mechanism = models.SmallIntegerField(choices={0: 'None', 1: 'Log files', 2: 'immutable ledger'}) - update_interval = models.CharField(max_length=2, choices={'-': 'never', 'W': 'weekly', 'M': 'monthly', 'Q': 'quarterly', 'A': 'annually', 'E': 'event_driven'}, default='-') +class Facility(models.Model): + """ + Describes a manufacturing facility, + i.e. a place where production takes place. + """ + uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False, help_text="Unique facility identifier") + operator = models.ForeignKey(Company, on_delete=models.RESTRICT) + country = CountryField() + address = models.TextField(max_length=100) + class Meta: + verbose_name_plural = 'Facilities' + unique_together = ['country', 'address', 'operator'] + ordering = ['operator', 'country', 'address'] + def __str__(self): - return f"DPP #{self.registration_number} issued by {self.issuer.name}" + return self.address.replace('\r\n', ', ') ## Documents -INSTRUCTION_TYPES = { - 'installation': 'Installation / assembly', - 'use': 'Use', - 'repair': 'Repair', - 'maintenance': 'Maintenance', - 'refurbishment': 'Refurbishment', - 'disassembly': 'Disassembly', - 'disposal': 'Disposal', -} - class Instruction(models.Model): - label = models.CharField(max_length=20, primary_key=True, choices=INSTRUCTION_TYPES, unique=True) + label = models.CharField(max_length=20, unique=True) def __str__(self): - return self.get_label_display() + return self.label -# class TechnicalDrawings(models.Model): class Document(models.Model): #TODO: security check on files DOCUMENT_TYPES = { + 'other': 'Other', "Technical document": - [ - ("technical_drawing", "Technical drawing"), - ("safety_sheet", "Safety sheet"), - ("conformity_certificate", "Conformity certificate"), - ("mass_balance", "Mass balance"), - ("energy_balance", "Energy balance"), - ("other", "Other"), - ], + { + 'technical_drawing': 'Technical drawing', + 'safety_sheet': 'Safety sheet', + 'conformity_certificate': 'Conformity certificate', + 'mass_balance': 'Mass balance', + 'energy_balance': 'Energy balance', + 'datasheet': 'Product data sheet', + }, "Compliance document": {'compliance': 'Compliance report', 'quality_cert': 'Quality certificate', 'safety_data': 'Safety data sheet', 'legal': 'Legal document', 'labor': 'Labor compliance', 'qms': 'Quality Management System certificate', 'warranty': 'Warranty information', 'spare_parts': 'Spare parts availability', 'takeback': 'Return and take-back'}, - "Manuals": - {'manual': 'User manual', 'maintenance': 'Maintenance manual', 'installation': 'Installation guide', 'eol': 'End-of-life guidelines', 'datasheet': 'Product data sheet'}, - "Labels": - {'label': 'Voluntary label', 'energy_label': 'Energy label', 'ecolabel': 'Ecolabel', 'recycling_label': 'Recycling label', 'legal': 'Legal markings'}, + "Manual": + {'manual': 'User manual', 'maintenance': 'Maintenance manual', 'installation': 'Installation guide', 'eol': 'End-of-life guidelines'}, #FIXME: remove manual types + "Label": + {'label': 'Voluntary label', 'energy_label': 'Energy label', 'ecolabel': 'Ecolabel', 'circularity_label': 'Circularity label', 'legal': 'Legal markings'}, } file = models.FileField(upload_to='documents/') type = models.CharField( - "Document type", max_length=25, choices=DOCUMENT_TYPES + "Document type", max_length=25, choices=DOCUMENT_TYPES, default='other' ) - instructions = models.ManyToManyField(Instruction, blank=True, help_text="Instructions included in this document (ony for manauals)") + issuer = models.ForeignKey(Organization, blank=True, null=True, on_delete=models.SET_NULL, help_text="Author, issuer or publisher") + instructions = models.ManyToManyField(Instruction, blank=True, help_text="Select all that apply. Instructions included in this document (ony for manauals)") language = models.CharField(max_length=40, blank=True) # file_type = models.CharField(max_length=5, default=file.split('.')[-1]) - file_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - upload_date = models.DateTimeField(auto_now_add=True) + issue_date = models.DateTimeField(default=datetime.date.today) + expiry_date = models.DateTimeField(blank=True, null=True) - def clean(self): - # Validate that instructions are only set for manuals - if self.type in self.DOCUMENT_TYPES['Manuals']: - if not self.instructions.exists(): - raise ValidationError({ - "instructions": "A manual must have at least one instruction type." - }) - elif self.instructions.exists(): - raise ValidationError({"instructions": "Instructions can only be associated with manuals."}) - def __str__(self): return self.file.name.split('/')[-1] @property def filename(self): return self.file.name.split('/')[-1] -# class ComplianceDocument(Document): -# super.type = models.CharField(choices=DOCUMENT_TYPES['Compliance document']) +@receiver(m2m_changed, sender=Document.instructions.through) +def validate_instructions(sender, instance, action, **kwargs): + """Ensure that all and only manuals have instructions.""" + if action == "post_add" or action == "post_remove" or action == "post_clear": + manuals = instance.DOCUMENT_TYPES['Manuals'] + if instance.type in manuals and not instance.instructions.exists(): + raise ValidationError("A manual must have at least one instruction type.") + if instance.instructions.exists() and instance.type not in manuals: + raise ValidationError("Instructions can only be associated with manuals.") -# class Manual(Document): -# language = models.CharField(max_length=40) -# type = models.CharField(default='manual', max_length=20, choices={'manual': 'User manual', 'circularity': 'Circularity manual', 'maintenance': 'Maintenance manual', 'installation': 'Installation guide', 'eol': 'End-of-life guidelines', 'datasheet': 'Product data sheet'}) -# class Labels(Document): -# type = models.CharField(max_length=20, default='label', choices={'label': 'Voluntary label', 'energy_label': 'Energy label', 'ecolabel': 'Ecolabel', 'recycling_label': 'Recycling label', 'legal': 'Legal markings'}) +## Technosphere: products and processes +class Flow(models.Model): + """Base class for (physical) flows of components and producs. + """ + def __str__(self): + if hasattr(self, "productmodel"): + return str(self.productmodel) + elif hasattr(self, "productbatch"): + return str(self.productbatch) -## Technosphere: products and processes + return f"Unspecified flow #{self.pk}" + + @property + def model(self): + if hasattr(self, "productmodel"): + return self.productmodel + elif hasattr(self, "productbatch"): + return self.productbatch.model + else: + return self + + @property + def manufacturer(self): + """Get the manufacturer of this product (operator of the + Facility hosting the Process that produces this product). + """ + try: + return self.produced_by.facility.operator + except AttributeError: + return None + + #TODO: only works for linear supply chains. No infinite loop detection. Fix with Leontief matrix. + def calc_composition(self, main_line): + """ + Recursively collect the Composition of components, + by searching upstream processes. + Returns a dict of {crm_id: country_code}. + """ + composition = defaultdict(float) + + # Use known composition for this product, if it comes from background + if not hasattr(self, 'produced_by') or self.produced_by.production_line != main_line: + if any(bom := self.composition.all()): + for entry in bom: + composition[entry.material] = entry.quantity * CONVERSIONS[entry.unit] * 1000 + return composition + + # Recurse into components + for input in self.produced_by.prod_exchanges.filter(type__in=['prod', 'waste']): + component_bom = input.product.calc_composition(main_line) + plm = -1 if input.type == 'waste' else 1 # Subtract waste materials + for material, value in component_bom.items(): + composition[material] += input.amount * value * plm + return composition + + def get_composition(self, recalculate=False): + """Make a Composition table for this product. + Calculate from supply chain if needed. + Returns a QuerySet with all materials + """ + if hasattr(self, 'produced_by') and (recalculate or not self.composition.all()): + production_line = self.produced_by.production_line + composition = self.calc_composition(production_line) + if len(composition) == 0: + print("No material composition specified for any component.") + for mat, value in composition.items(): + Composition.objects.update_or_create( + product=self, material=mat, defaults={'quantity': value} + ) + return self.composition.all() + + def get_hazardous_concentrations(self): + """Returns a dict with the concentration of each hazardous material""" + concentrations = defaultdict(float) + try: + product_weight = self.properties.weight * CONVERSIONS[self.properties.weight_unit.unit] + except ProductProperties.DoesNotExist: + print(f"Weight of {self} unknown; cannot calculate concentration.") + bom = self.get_composition() + for content in bom: + if isinstance(mat := content.material, HazardousMaterial): + concentrations[mat] += content.quantity * CONVERSIONS[content.unit] / product_weight + return concentrations + + def find_missing_bom(self): + """Find direct components without a composition""" + if not hasattr(self, 'produced_by'): + return [] + missing = [] + for flow in self.produced_by.prod_exchanges.filter(type__in=['prod', 'waste']): + # Check if this flow has any composition data + if not any(flow.product.composition.all()): + missing.append(flow) + return missing + + def add_concentrations(self): + """Make a Concentration table for this product, using Composition. + Also add the packaging ratio to the Concentration table. + """ + concentrations = self.get_hazardous_concentrations() + + for material, frac in concentrations.items(): + Concentration.objects.update_or_create( + product=self, material=material, fraction=frac + ) + packaging = Material.objects.update_or_create(name='Total packaging material') + if hasattr(self, 'properties'): + Concentration.objects.update_or_create( + product=self, + material=packaging, + fraction=self.properties.packaging_ratio, + ) + + def add_components(self): + """Make a Component table for this product, using exchange data. + """ + if hasattr(self, 'manufacturing_info'): + activity = self.manufacturing_info + elif hasattr(self, 'produced_by'): + activity = self.produced_by + + for exch in activity.prod_exchanges.filter(type='prod', direction='in'): + Component.objects.update_or_create( + product=self, component=exch.product, amount=exch.amount / activity.amount + ) + + def add_subcomponents(self, component): + """ + Add all subcomponents of `component` to this product. + If a subcomponent already exists, its amount is increased. + """ + this_entry = Component.objects.get(product=self, component=component) + if not this_entry.exists(): + raise Component.DoesNotExist( + f"'{self}' does not contain component '{component}'" + ) + subcomponents = component.composed_of.all() + if not subcomponents.exists(): + print(f"No components found for {component}") + return + with transaction.atomic(): + for subcomp in subcomponents: + # Update, or if component already exists, sum amounts + new_amount = this_entry.amount * subcomp.amount + Component.objects.update_or_create( + product=self, + component=subcomp.component, + defaults={'amount': models.F('amount') + new_amount}, + ) + this_entry.delete() + +class ProductModel(Flow): + """Describes a specific model or version of a product. + All items of a product model share the same design, weight, and manufacturer. + """ + name = models.CharField("Model or product name", max_length=100) + unit = models.CharField(max_length=15, default='pcs', help_text="How the product is counted, e.g. pcs, bottles, sheets, kWh") + brand = models.CharField(max_length=50, blank=True) + description = models.TextField(max_length=200, blank=True) + # unit_price = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True) -class Material(models.Model): - name = models.CharField("Material name", max_length=50) - density = models.FloatField(blank=True) - recycled_content = models.FloatField("Recycled content (%)", default=0, validators=FRACTION_VALIDATOR) - recyclable_percentage = models.FloatField("Recyclable material (%)", default=0, validators=FRACTION_VALIDATOR) - biobased_percentage = models.FloatField("Bio-based material (%)", default=0, validators=FRACTION_VALIDATOR) - reused_fraction = models.FloatField("Reused material (%)", default=0, validators=FRACTION_VALIDATOR) - renewable_fraction = models.FloatField("Sustainable and renewable material (%)", default=0, validators=FRACTION_VALIDATOR) + taric_code = models.CharField("TARIC code", max_length=20, blank=True, help_text="(customs code)") + hs_code = models.CharField("HS code", max_length=10, blank=True, help_text="Harmonized System classification (customs code)") def __str__(self): return self.name -class HazardousMaterial(Material): - CAS_number = models.CharField(max_length=50, blank=True, unique=True) - safety_instructions = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='material_safety_instructions') # (SafetyDataSheet) - substance_concentration = models.FloatField(blank=True) # Not used anywhere? - concentration_unit = models.CharField(max_length=20, blank=True) - substance_location = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='material_location') # (TechnicalDrawings) - -class CriticalRawMaterial(Material): - origin_country = CountryField() - supply_risk_level = models.CharField(max_length=10) - substance_concentration = models.FloatField() - concentration_unit = models.CharField(max_length=20) - -class ProductType(models.Model): - UNIT_CHOICES = { - 'pcs': 'pieces', - 'Mass': { - 'kg': 'kg', - 'g': 'g', - 'lb': 'lb', - 'oz': 'oz', - }, - 'Volume': { - 'l': 'liters', - 'cm3': 'cm3', - 'dm3': 'dm3', - 'm3': 'cubic meters', - 'ft3': 'cubic feet', - }, - } - name = models.CharField("Model or product name", max_length=100) - unit = models.CharField(max_length=3, choices=UNIT_CHOICES, default='pcs') - description = models.TextField(max_length=200, blank=True) - unit_price = models.DecimalField(max_digits=10, decimal_places=2, blank=True) - weight = models.FloatField(default=1, validators=[MinValueValidator(0)]) +class ProductBatch(Flow): + batch_number = models.PositiveIntegerField() + model = models.ForeignKey(ProductModel, on_delete=models.RESTRICT, related_name='batch') + GTIN = models.CharField(max_length=13, unique=True, help_text="Global Trade Item Number (or EAN)") + + class Meta: + verbose_name_plural = 'Product batches' + + def __str__(self): + return f"{self.model} batch {self.batch_number}" + + def clean(self): + if not hasattr(self.model, "productmodel"): + raise ValidationError("Model must be a ProductModel") + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + +class ProductProperties(models.Model): + """Physical properties of a product.""" + product = models.OneToOneField(Flow, on_delete=models.CASCADE, related_name='properties') + weight = models.FloatField("Weight of 1 unit", validators=[MinValueValidator(0)]) weight_unit = models.CharField(max_length=2, choices=UNIT_CHOICES['Mass'], default='kg') volume = models.FloatField(validators=[MinValueValidator(0)]) volume_unit = models.CharField(max_length=3, choices=UNIT_CHOICES['Volume'], default='m3') - vendor_or_importer = models.ForeignKey(Importer, blank=True, on_delete=models.SET(get_unknown_importer), related_name='sold_products') - origin = models.ForeignKey(Company, on_delete=models.SET(get_unknown_company), related_name="manufactured_products") + includes_packaging = models.BooleanField("The above includes packaging", default=False) + density = models.FloatField(validators=[MinValueValidator(0)], help_text='Density of the product, excluding packaging and empty space.') - taric_code = models.CharField(max_length=20, blank=True, help_text="TARIC (customs code)") - hs_code = models.CharField(max_length=10, blank=True, help_text="Harmonized System classification (customs code)") + @property + def density_unit(self): + return f"{self.weight_unit}/{self.volume_unit}" + @property + def packaging_ratio(self): + if self.weight == 0: + return 0 + package_weight = 0 + for pack in self.produced_by.prod_exchanges.filter(type='pack'): + package_weight += pack.properties.weight * CONVERSIONS[pack.weight_unit] + if self.includes_packaging: + return package_weight / (self.weight - package_weight) + else: + return package_weight / self.weight + @property + def net_weight(self): + if self.includes_packaging: + return self.weight / (self.packaging_ratio + 1) + else: + return self.weight + + +class DppDetails(models.Model): + """Detailed info about a product, as required for the Digital Product Passport (DPP). + Typically needed for final products sold in stores. + """ + product = models.OneToOneField(Flow, on_delete=models.CASCADE, related_name='details', primary_key=True) + importer = models.ForeignKey(Importer, blank=True, null=True, on_delete=models.SET(get_unknown_importer), related_name='imported_products', help_text="Specify if the product is imported from outside the EU.") + + #Classification + CPV_code = models.CharField(max_length=8, blank=True, help_text="Common Procurement Vocabulary code") + GS1_GPC_code = models.CharField(max_length=9, blank=True, help_text="Global Product Classification code") # Documents and other quality compliance info - quality_compliance_documents = models.ManyToManyField(Document, blank=True) - # warranty_period_months = models.PositiveSmallIntegerField(default=0, help_text="Warranty period in months") #TODO: check how users can enter data in months, then convert to duration - warranty_duration = models.DurationField(default=datetime.timedelta(0), help_text="Warranty duration as a time delta") - spare_parts_availability_duration = models.DurationField(default=datetime.timedelta(0), help_text="Spare parts availability duration as a time delta") - takeback_system = models.CharField(max_length=10, choices={'no': 'No take-back system', 'basic': 'Collection on request', 'active': 'Structured take-back with dedicated channels or collection points', 'advanced': 'Certified, traceable take-back system'}, default='no') - # technical_drawings = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='product_drawings') - # conformity_certificate = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='product_conformity_certificate') + compliance_documents = models.ManyToManyField(Document, blank=True) + warranty_period = models.DecimalField(default=0, max_digits=3, decimal_places=1, validators=[MinValueValidator(0)], help_text="Warranty period in years") + spare_parts_availability_duration = models.DecimalField(default=0, max_digits=3, decimal_places=1, validators=[MinValueValidator(0)], help_text="Spare parts availability in years") + takeback_system = models.CharField("Take-back system", max_length=10, choices={'no': 'No take-back system', 'basic': 'Collection on request', 'active': 'Structured take-back with dedicated channels or collection points', 'advanced': 'Certified, traceable take-back system'}, default='no') - def __str__(self): - return self.name + class Meta: + verbose_name = verbose_name_plural = "DPP details" -class Packaging(ProductType): - pass + def __str__(self): + return f"Details for {self.product}" -class SecondaryProduct(ProductType): +class SecondaryProduct(ProductModel): CIRCULARITY_CHOICES = { 'R3': 'reused', 'R5': 'refurbished', @@ -230,87 +431,334 @@ class SecondaryProduct(ProductType): '-': 'unknown', } circularity = models.CharField(max_length=2, choices=CIRCULARITY_CHOICES, default='-') - is_waste = models.BooleanField(choices={True: "Yes", False: "No"}, default=False) + is_waste = models.BooleanField(default=False) -class Emission(models.Model): - name = models.CharField(max_length=50) +class ProductItem(models.Model): + product_batch = models.ForeignKey(ProductBatch, on_delete=models.PROTECT) + serial_number = models.CharField(max_length=50, unique=True) #FIXME: make only the combination of product and manufacturer unique? + production_date = models.DateField(default=datetime.date.today) + circularity = models.CharField(max_length=50, default="new") + + def update_circularity(self, circularity_code): + """Append circularity_code to self.circularity""" + allowed_values = ['R3', 'R5', 'R6', 'R7', 'R8', '-'] + assert circularity_code in allowed_values, ( + "Expecting one of the following circularity codes: " + + ', '.join(allowed_values) + ) + self.circularity += ',' + circularity_code + self.save() def __str__(self): - return self.name + return f"Product #{self.serial_number}" + + def disassemble(self): + """ + Creates a ProductItem for each component. + Returns a list of created ProductItems. + """ + created_items = [] + + for i, component in enumerate(self.components.all()): #TODO: make this table + component_serial = f"{self.serial_number}-C{i}" + for j in range(component.amount): + # Generate unique serial number for each component + if component.amount > 1: + component_serial += f"-{j}" + + new_item = ProductItem.objects.create( + product_batch=component, + serial_number=component_serial, + GTIN_code="", # Components may not have GTIN initially + production_date=self.production_date, + ) + created_items.append(new_item) + + return created_items + +class Metadata(models.Model): + """Transparency information related to a ProductItem.""" + product_item = models.OneToOneField(ProductItem, on_delete=models.PROTECT, related_name='dpp_metadata') + registration_number = models.UUIDField(primary_key=True, default=uuid4, editable=False) + issuer = models.ForeignKey(Organization, on_delete=models.PROTECT) + reo = models.ForeignKey(Company, on_delete=models.PROTECT, verbose_name='Responsible economic operator',related_name='reo_of', help_text="The entity bearing legal responsibility for the DPP and the product.") + creation_date = models.DateField(auto_now_add=True) + last_modified = models.DateField(auto_now=True) + version = models.CharField(max_length=10) + language = models.CharField(max_length=20, default='EN', help_text="Language used in descriptions") + # Data access & governance + access_link = models.URLField(max_length=200, blank=True, help_text="URL to full DPP record.") + access_policy = models.URLField(max_length=200, blank=True, help_text="URL to data access terms and conditions.") + access_log_enabled = models.BooleanField(default=True) + verification_type = models.SmallIntegerField(choices={0: 'None', 1: 'Digital signature', 2: 'Third party', 3: 'Blockchain'}, default=0) + credential_format = models.CharField(max_length=50, choices={'json_ld': 'JSON-LD', 'verifialble':'Verifiable credential', 'xml': 'XML', 'other': 'Other'}) + storage_location = models.SmallIntegerField(choices={0: 'Undeclared', 1: 'On-premise server', 2: 'Commercial cloud server', 3: 'Centralized certified server', 4: 'Decentralized storage'}, default=0) + audit_trail_mechanism = models.SmallIntegerField(choices={0: 'None', 1: 'Log files', 2: 'Immutable ledger'}, default=0) + update_interval = models.CharField(max_length=2, choices={'-': 'never', 'W': 'weekly', 'M': 'monthly', 'Q': 'quarterly', 'A': 'annually', 'E': 'event_driven'}, default='-') -class Composition(models.Model): - product = models.ForeignKey(ProductType, on_delete=models.CASCADE, related_name='composition') - material = models.ForeignKey(Material, on_delete=models.CASCADE, related_name='used_in') - fraction = models.FloatField(validators=FRACTION_VALIDATOR) + class Meta: + verbose_name_plural = "Metadata" + + def __str__(self): + return f"DPP #{self.registration_number} issued by {self.issuer.name}" + +class Emission(models.Model): + name = models.CharField(max_length=70, unique=True) + unit = models.CharField(max_length=10, default='g') class Meta: - unique_together = ('product', 'material') - ordering = ['product', 'material'] + ordering = ['name'] def __str__(self): - return f"{self.amount}% {self.material} in ({self.product})" + return self.name -class Product(models.Model): # =ProductInformation in DPP - product_type = models.ForeignKey(ProductType, on_delete=models.PROTECT) - DPP_metadata = models.ForeignKey(Metadata, on_delete=models.PROTECT, blank=True) - serial_number = models.CharField(max_length=50, unique=True) #FIXME: make only the combination of product and manufacturer unique? - batch_number = models.IntegerField(blank=True) - CPV_code = models.CharField(max_length=20, blank=True, help_text="Common Procurement Vocabulary code") - GS1_GPC_code = models.CharField(max_length=20, blank=True, help_text="Global Product Classification code") - GTIN_code = models.CharField(max_length=20, help_text="Global Trade Item Number (or comparable)") - production_date = models.DateField(default=datetime.date.today) + +## Technosphere: Activities and Exchanges + +class Activity(models.Model): + name = models.CharField(max_length=100) + amount = models.FloatField(default=1, help_text="Reference number of units produced") + facility = models.ForeignKey(Facility, on_delete=models.CASCADE, help_text="Production location", blank=True, null=True) + description = models.TextField(max_length=300, blank=True) + + class Meta: + verbose_name_plural = "Activities" def __str__(self): - return f"Product #{self.name}" + return self.name + + def aggregate_biosphere(self, process_demands: pd.Series): + """ + Copy and aggregate environmental exchanges to self + Args: + self (Activity): Process for which to create biosphere + process_demands: pd.Series of scaling factors, + with as index process IDs (of processes to be aggregated) + """ + EnvExchange.objects.filter(process=self).delete() + # Fetch exchanges + exchanges = ( + EnvExchange.objects + .filter(process_id__in=process_demands.index) + .select_related("process", "substance") + ) + # Copy the exchanges to self + with transaction.atomic(): + for ex in exchanges: + try: #NOTE: amount is always positive + new_ex = EnvExchange.objects.get( + process=self, substance=ex.substance, + compartment=ex.compartment, direction=ex.direction, + ) + new_ex.amount += ex.amount * process_demands[ex.process.id] + new_ex.save() + except EnvExchange.DoesNotExist: # Copy, edit, save + ex.id = None + ex.pk = None + ex.amount *= process_demands[ex.process.id] + ex.process = self + ex.save() + +class ManufacturingProcess(Activity): + """Aggregated manufacturing process that will be published + along with a DPP. + """ + functional_flow = models.OneToOneField(Flow, on_delete=models.RESTRICT, verbose_name="Main product", related_name='manufacturing_info', help_text="The output product of this manufacturing process.") + modified_at = models.DateField(auto_now=True) + mass_balance = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='mass_balance', help_text="A document showing all material exchanges of the process.") + energy_balance = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='energy_balance', help_text="A document showing all energy flows exchanges of the process.") + + def clean(self): + if not self.facility: + raise ValidationError({ + 'facility': "'Facility' cannot be blank. Please specify it." + }) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) class ProductionLine(models.Model): + """Describes a part of a supply chain, operated by one manufacturer. + Used to support data collection, not published is DPP. + """ name = models.CharField(max_length=100) description = models.TextField(max_length=300, blank=True) - final_product = models.ForeignKey(ProductType, on_delete=models.RESTRICT) - operator = models.ForeignKey(Company, blank=True, on_delete=models.SET(get_unknown_company)) + final_product = models.OneToOneField(Flow, on_delete=models.RESTRICT, verbose_name="Final product", help_text="The output product of this production line") + facility = models.ForeignKey(Facility, on_delete=models.CASCADE, help_text="Production location", blank=True, null=True) modified_at = models.DateField(auto_now=True) - mass_balance = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='mass_balance') - energy_balance = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='energy_balance') + mass_balance = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='pl_mass_balance', help_text="Add a document showing all material flows going in and out of the production line. (Optional)") + energy_balance = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='pl_energy_balance', help_text="Add a document showing all energy flows going in and out of the production line. (Optional)") + created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='production_lines') def __str__(self): return self.name -class Process(models.Model): - production_line = models.ForeignKey(ProductionLine, on_delete=models.CASCADE) # Assuming 1:M - name = models.CharField(max_length=100) - is_outsourced = models.BooleanField(choices={True: "Yes", False: "No"}, default=False) - operator = models.ForeignKey(Company, blank=True, null=True, on_delete=models.CASCADE) - location = CountryField(blank=True) - # energy_use = models.FloatField() - functional_flow = models.ForeignKey(ProductType, blank=True, null=True, on_delete=models.SET_NULL) - created_at = models.DateTimeField(auto_now_add=True) + def check_unused_outputs(self): + """ + Check which Process functional_flows are not linked to another Process. + Return a message if unused outputs differ from the final_product. + """ + process_list = self.bop.all() + unused_outputs = [] + + for process in process_list: + # Check if this process's output is used as input in any Exchange + func_flow = process.functional_flow + is_linked = ProductExchange.objects.filter( + product=func_flow, process__in=process_list + ).exists() + + if not is_linked and func_flow != self.final_product: + unused_outputs.append(func_flow) + + if unused_outputs: + product_names = ', '.join([str(p) for p in unused_outputs]) + return f"Warning: Products [{product_names}] are not linked to other processes." + + return "" # All good + + def check_missing_origins(self): + """Check which inputs are not produced anywhere. + """ + missing = [] + for input in Flow.objects.filter(exchanged_by__process__in=self.bop.all()): + if not (hasattr(input, 'produced_by') or hasattr(input, 'manufacturing_info')): + missing.append(str(input)) + if missing: + return f"Warning: Production process missing for {missing}" + return "" # All good + + def create_transport(self): + """Find all the input products of this production line + and create a transport entry with default distance and mode. + """ + processes = self.bop.all() + inputs = Flow.objects.filter( + exchanged_by__process__in=processes + ).exclude(produced_by__in=processes).distinct() + for prod in inputs: + if not Transport.objects.filter(production_line=self, product=prod).exists(): + Transport(production_line=self, product=prod, distance=150).save() + + def aggregate_production(self): + """ + Aggregate all processes in ProductionLine into a ManufacturingProcess. + """ + ## Collect process, product, and exchange info + processes = self.bop.all().order_by('id') + + inputs = Flow.objects.filter( + exchanged_by__process__in=processes + ).distinct() + suppliers = ManufacturingProcess.objects.filter(functional_flow__in=inputs) + # Inputs that come from another production line: aggregate that pl first + other_source = Process.objects.filter(functional_flow__in=inputs).exclude(production_line=self) + pl_map = {} #FIXME: unused ManufacturingProcess to Process map + for p in other_source: + pl = p.production_line + mp = pl.create_public_tables() + suppliers += [mp] + pl_map[mp.pk] = p.pk + + exchanges = ( + ProductExchange.objects + .filter(process__in=processes) + .select_related("product", "process") + ) + # Create sorted list of products + products = set(ex.product for ex in exchanges) + products |= {p.functional_flow for p in processes if p.functional_flow} + products = sorted(products, key=lambda p: p.id) + + ## Create technosphere matrix + A = pd.DataFrame( + 0.0, index=[p.id for p in products], columns=[p.id for p in processes] + ) + # Fill with exchanges add functional flows (main outputs) + for ex in exchanges: + sign = 1 if ex.direction == "in" else -1 + A.at[ex.product_id, ex.process_id] += sign * ex.amount + for process in processes: + A.at[process.functional_flow.id, process.id] = -process.amount + for sup in suppliers: + A[sup.id] = 0.0 + A.at[sup.functional_flow.id, sup.id] = -sup.amount + + # Label axes for readability + A.index.name = "product_id" + A.columns.name = "process_id" + + ## Solve system + assert len(A) == len(A.index), "Matrix must be square" + f = np.zeros(len(A)) + fu_loc = A.index.get_loc(self.final_product.id) + f[fu_loc] = self.final_product.produced_by.amount # Functional unit + s = -np.linalg.solve(A, f) # scaling factors or total supply + s = pd.Series(s, index=A.columns) + + ## Create aggregated process + exchanges + aggregated, created = ManufacturingProcess.objects.update_or_create( + name=self.final_product.model.name + " production", + amount=f[fu_loc], + facility=self.facility, + description=self.description, + functional_flow=self.final_product, + # production_line=self, + ) + ProductExchange.objects.filter(process=aggregated).delete() + for sup in suppliers: + value = s[sup.id] + ex_type = ProductExchange.objects.filter(product=sup.functional_flow, process__in=processes)[0].type + ProductExchange.objects.create( + process=aggregated, + product=sup.functional_flow, + amount=abs(value), + direction='in' if value>=0 else 'out', + type=ex_type, + ) + + aggregated.aggregate_biosphere(s.iloc[:len(processes)]) + + return aggregated + + +class Process(Activity): + """Internal subprocess, used for convenient modeling of a production line""" + production_line = models.ForeignKey(ProductionLine, on_delete=models.CASCADE, related_name='bop') + functional_flow = models.OneToOneField(Flow, blank=True, null=True, on_delete=models.SET_NULL, verbose_name="Main output", related_name='produced_by') + is_outsourced = models.BooleanField("Outsourced", default=False) + created_at = models.DateField(auto_now_add=True) modified_at = models.DateTimeField(auto_now=True) def clean(self): if not self.functional_flow: - raise ValidationError("functional_flow cannot be blank. Please select a product.") + raise ValidationError("'Main output' cannot be blank. Please select a product.") def save(self, *args, **kwargs): - # If operator is not set, default to production line operator - if self.production_line and not self.operator: - self.operator = self.production_line.operator - if not self.location: - self.location = self.operator.country + # If facility is not set, default to production line facility + if self.production_line: + if self.facility: + self.is_outsourced = (self.facility != self.production_line.facility) + else: # not self.facility + self.facility = self.production_line.facility + self.clean() super().save(*args, **kwargs) + + class Meta: + verbose_name_plural = "Processes" - def __str__(self): - return self.name +class BackgroundProcess(ManufacturingProcess): + created_at = models.DateField(auto_now_add=True) + database = models.CharField(max_length=50, blank=True) + db_code = models.CharField(max_length=50, blank=True, help_text="Unique ID in the source database") + tags = models.CharField(max_length=150, blank=True) + type = models.CharField(max_length=50, blank=True) -class SharedProcess(Process): - """ Represents processes that are shared across multiple production lines. - functional_flow defaults to the final product of the production line. - """ - def save(self, *args, **kwargs): - # If functional flow is not set, default to the final product of production line - if self.production_line and not self.functional_flow: - self.functional_flow = self.production_line.final_product - super().save(*args, **kwargs) + class Meta: + verbose_name = "Average market process" + verbose_name_plural = "Average market processes" class Exchange(models.Model): """ Represents the input to or output of a Process.""" @@ -323,175 +771,388 @@ class Exchange(models.Model): 'triangular': 'Triangular distribution', } amount = models.FloatField() - exchange_type = models.CharField(max_length=3, choices={'in': 'Input', 'out': 'Output', 'ff': 'functional flow'}) #NOTE: out means waste - is_proxy = models.BooleanField("The actual product is different", choices={True: "Yes", False: "No"}, default=False) - observed = models.BooleanField("Quantity is", choices={True: "Measured", False: "Modeled or calculated"}, default=False) - uncertainty_type = models.CharField(max_length=30, choices=UNCERTAINTY_TYPES, default='none') - loc = models.FloatField(blank=True) # mean or median - scale = models.FloatField(blank=True) # stddev or geometric stddev - shape = models.FloatField(blank=True) # for lognormal - minimum = models.FloatField(blank=True) # for interval and triangular - maximum = models.FloatField(blank=True) # for interval and triangular - # unit = models.CharField(max_length=20) #product.unit - # description = models.TextField(max_length=300, blank=True) + direction = models.CharField(max_length=3, choices={'in': 'Input', 'out': 'Output', 'ff': 'functional flow'}) #NOTE: out means waste + is_proxy = models.BooleanField("This is an approximation of the actual product", default=False) + is_observed = models.BooleanField("Quantity is", choices={True: "Measured", False: "Modeled or calculated"}, default=False) + comment = models.CharField(max_length=100, blank=True) + uncertainty_type = models.CharField(max_length=30, choices=UNCERTAINTY_TYPES, default='none', help_text="If the amount is uncertain, how can this uncertainty be described?") + loc = models.FloatField("Mean or mode", blank=True, null=True, help_text="for (log)normal and triangular distribution") + scale = models.FloatField("Standard deviation", blank=True, null=True, help_text="or log-space standard deviation") + minimum = models.FloatField(blank=True, null=True, help_text="for interval and triangular distribution") + maximum = models.FloatField(blank=True, null=True, help_text="for interval and triangular distribution") class Meta: abstract = True class ProductExchange(Exchange): - """Represents the input or output of a product by a process.""" - product = models.ForeignKey(ProductType, on_delete=models.CASCADE, related_name='used_in_process') - process = models.ForeignKey(Process, on_delete=models.CASCADE, related_name='prod_exchanges') + """Represents the input or output of a product by an activity.""" + product = models.ForeignKey(Flow, on_delete=models.CASCADE, related_name='exchanged_by') + process = models.ForeignKey(Activity, on_delete=models.CASCADE, related_name='prod_exchanges') + FLOW_TYPES = { + 'prod': 'Component (part of the product)', + 'cons': 'Consumable', + 'ener': 'Electricity or heat', + 'util': 'Utility or equipment', + 'serv': 'Service', + 'pack': 'Packaging', + 'react': 'Reactant', + 'waste': 'Waste', + } + type = models.CharField(max_length=5, choices=FLOW_TYPES) class Meta: - unique_together = ['product', 'process', 'exchange_type'] + unique_together = ['product', 'process', 'direction'] ordering = ['process', 'product'] + def clean(self): + if (self.direction == 'out') & (self.type != 'waste'): + raise ValidationError("'Type' of output flow must be 'waste'.") + + def save(self, *args, **kwargs): + # If type is not set, default to part or waste + if not self.type: + if self.direction == 'in': + self.type = 'prod' + else: + self.type = 'waste' + + self.clean() + super().save(*args, **kwargs) + def __str__(self): - return f"{self.exchange_type}: {self.amount} {self.product.unit} {self.product}" + return f"{self.direction}: {self.amount} {self.product.model.unit} {self.product}" class EnvExchange(Exchange): """Represents an emission or resource extraction by a process.""" COMPARTMENTS = { - 'air': 'air', - 'soil': 'soil', - 'groundwater': 'groundwater', - 'seawater': 'seawater', - 'surface_water': 'surface water', + 'Air': { + 'air-urban': 'Urban air', # close to ground + 'air-rural': 'Non-urban air or from high stacks', + 'air-lt': 'Long-term', # and low population density + 'air-indoor': 'Indoor', + 'air-strato': '10-30 km above ground', # 'lower stratosphere + upper troposphere' + 'air': 'Unspecified', + }, + 'uptake': 'Direct human uptake', + 'Soil': { + 'soil-agri': 'Agricultural', + 'soil-forest': 'Forest', + 'soil-indu': 'Industrial', + 'soil': 'Unspecified', + }, + 'Water': { + 'surface_water': 'Surface water', + 'seawater': 'Seawater', + 'groundwater': 'Groundwater', + 'groundwater-lt': 'Groundwater, long term', + 'groundwater-deep': 'Deep underground wells', + 'water': 'Unspecified', + }, } - substance = models.ForeignKey(Emission, on_delete=models.CASCADE) - process = models.ForeignKey(Process, on_delete=models.CASCADE, related_name='env_exchanges') + substance = models.ForeignKey(Emission, on_delete=models.CASCADE, related_name='exchanges') + process = models.ForeignKey(Activity, on_delete=models.CASCADE, related_name='env_exchanges') compartment = models.CharField(max_length=20, choices=COMPARTMENTS) class Meta: - unique_together = ['substance', 'compartment', 'exchange_type'] + unique_together = ['process', 'substance', 'compartment', 'direction'] ordering = ['process', 'substance'] + verbose_name = 'Emission or Extraction' + verbose_name_plural = 'Emissions & Extractions' + + def clean(self): + if self.direction == 'ff': + raise ValidationError("'Direction' must be either 'input' or 'output'.") + + def save(self, *args, **kwargs): + if not self.direction: + self.direction == 'out' + self.clean() + super().save(*args, **kwargs) def __str__(self): - return f"{self.exchange_type}: {self.amount} {self.substance.unit} {self.substance}" + return f"{self.direction}: {self.amount} {self.substance.unit} {self.substance}" -class BillOfMaterials(models.Model): - """ Represents the components contained in a ProductType.""" - product = models.ForeignKey(ProductType, on_delete=models.CASCADE, related_name='bom') # product or subclass Material - component = models.ForeignKey(ProductType, on_delete=models.CASCADE, related_name='part_of') # Could contain ProductType, Product, Material - amount = models.FloatField() - unit = models.CharField(max_length=20, choices=ProductType.UNIT_CHOICES) # Choices validated below +class Alias(models.Model): + """Allow companies to define an alternative product name to display""" + product = models.ForeignKey(Flow, on_delete=models.CASCADE, related_name='alias') + user = models.ForeignKey(Company, on_delete=models.CASCADE) + alt_name = models.CharField("Display name", max_length=100) + + class Meta: + verbose_name_plural = "Aliases" + + def __str__(self): + return f"{self.product} = {self.alt_name}" - @property # Dynamic choices of units based on product type - def find_units(self): - if self.component.product_type.unit == 'pcs': - return ['pcs'] +class Transport(models.Model): + """Table of transport distance and vehicle + for inputs to a production line. + """ + VEHICLES = { + 'ocean': 'Ship (ocean)', + 'NA': 'Unspecified', + } + production_line = models.ForeignKey(ProductionLine, on_delete=models.CASCADE, related_name='transport') + product = models.ForeignKey(Flow, on_delete=models.CASCADE, related_name='transport') + distance = models.PositiveSmallIntegerField("Transport distance (km)", default=0, validators=[MaxValueValidator(40000)]) + mode = models.CharField("Main mode of transport", max_length=10, choices=VEHICLES, default='NA') + utilisation_ratio = models.FloatField(default=0.5, validators=FRACTION_VALIDATOR) + #TODO: if mode='car', calculate allocation factor as: min(1, prod.volume / 0.2 m3). + + def __str__(self): + return f"{self.distance} km by {self.VEHICLES[self.mode]}" + + class Meta: + unique_together = ('production_line', 'product') + + +## Composition and Materials + +class Material(models.Model): + name = models.CharField("Material name", max_length=50) + density = models.FloatField(blank=True, default=0) + recycled_fraction = models.FloatField("Recycled content (%)", default=0, validators=FRACTION_VALIDATOR) + recyclable_fraction = models.FloatField("Recyclable material (%)", default=0, validators=FRACTION_VALIDATOR) + biobased_fraction = models.FloatField("Bio-based material (%)", default=0, validators=FRACTION_VALIDATOR) + # reused_fraction = models.FloatField("Reused material (%)", default=0, validators=FRACTION_VALIDATOR) #FIXME: N/A + renewable_fraction = models.FloatField("Sustainable and renewable material (%)", default=0, validators=FRACTION_VALIDATOR) + chemical_formula=models.CharField(max_length=30, blank=True) + + criticality_level = models.CharField(max_length=1, blank=True, default='', choices={'': 'N/A', 'c': 'critical', 'h': 'high', 'm': 'intermediate'}, help_text="Only for Critical Raw Materials (CRMs): criticality indicator based on supply risk and economic importance.") + origin_country = CountryField("Country of origin", blank=True, null=True, help_text="Only for Critical Raw Materials (CRMs)") + + def __str__(self): + if self.origin_country: + return f"{self.name} ({self.origin_country.code})" else: - return ProductType.UNIT_CHOICES['Mass'].keys() | ProductType.UNIT_CHOICES['Volume'].keys() + return self.name + + class Meta: + # unique_together = ('name', 'origin_country') # If always the same %'s + ordering = ['name', 'origin_country'] + + @property + def is_hazardous(self): + return hasattr(self, 'hazardousmaterial') + + @property + def is_critical(self): + return bool(self.criticality_level) def clean(self): - if self.product == self.component: - raise ValidationError("A product cannot contain itself as a component.") - if self.unit and self.unit not in self.find_units(): - raise ValidationError(f"Invalid unit '{self.unit}'. Allowed: {', '.join(self.find_units())}.") + if bool(self.criticality_level) != bool(self.origin_country): + raise ValidationError({ + 'criticality_level': "If this is a CRM, 'Country of origin' must also be specified.", + 'origin_country': "If this is a CRM, 'Criticality level' must also be specified.", + }) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + +class HazardousMaterial(Material): + CAS_number = models.CharField(max_length=50, unique=True) + safety_instructions = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='material_safety_instructions') # (SafetyDataSheet) + +class Composition(models.Model): + product = models.ForeignKey(Flow, on_delete=models.CASCADE, related_name='composition') + material = models.ForeignKey(Material, on_delete=models.PROTECT, related_name='used_in') + quantity = models.FloatField(help_text="The amount of material present in product.") + unit = models.CharField(max_length=2, choices=UNIT_CHOICES['Mass'], default='g') class Meta: - unique_together = ('product', 'component') - ordering = ['product', 'component'] + unique_together = ('product', 'material') + ordering = ['product', 'material'] + + def __str__(self): + return f"{self.quantity} {self.unit} {self.material} (in {self.product})" + +class Concentration(models.Model): + """Describes the concentration fo Substances of Concern in a product, + and the ratio of packaging vs. product weight. + """ + product = models.ForeignKey(Flow, on_delete=models.CASCADE, related_name='concentration') + material = models.ForeignKey(Material, on_delete=models.PROTECT, related_name='concentration_in') + fraction = models.FloatField(validators=FRACTION_VALIDATOR) + class Meta: + unique_together = ('product', 'material') + ordering = ['product', 'material'] + def __str__(self): - return f"{self.amount} {self.unit} {self.component} in ({self.product})" + return f"{self.fraction:.1%} {self.material} (in {self.product})" -class PackagingInfo(models.Model): - product = models.ForeignKey(ProductType, on_delete=models.CASCADE, related_name='packaging_info') - packaging = models.ForeignKey(Packaging, on_delete=models.CASCADE, related_name='used_as_packaging') - packaging_ratio = models.FloatField() +class Component(models.Model): + """Describes (replaceable) components contained in products. + Sometimes called 'Bill of Materials'. + This information is always derived from exchanges. + """ + product = models.ForeignKey(Flow, on_delete=models.CASCADE, related_name='composed_of') + component = models.ForeignKey(Flow, on_delete=models.PROTECT, related_name='part_of') + amount = models.PositiveSmallIntegerField() + + class Meta: + verbose_name = "Replaceable component" + unique_together = ('product', 'component') + ordering = ['product', 'component'] + + def clean(self): + if self.product == self.component: + raise ValidationError("A product cannot contain itself as a component.") + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) def __str__(self): - return f"{self.product} packaged in {self.packaging}" + return f"{self.amount} x {self.component} (in {self.product})" ## Service and maintenance records -class ServiceEvent(models.Model): - LIFE_STAGES = { - 'upstream': 'Upstream', - 'manufacturing': 'Manufacturing stage', - 'use': 'Use phase', - 'eol': 'End-of-life stage', - } - SERVICE_TYPES = { - 'preventive_maintenance': 'Preventive maintenance', - 'corrective_maintenance': 'Corrective maintenance', - 'modification': 'Modification', - 'upgrade': 'Upgrade', - 'eol': 'End-of-life treatment', +class LifeCycleEvent(models.Model): + """An activity or event during the life cycle of a product item. + In line with UNTP Traceability Event. + """ + EVENT_TYPES = { + 'sales': 'Sales or ownership transfer', + 'test': 'Inspection', + 'Maintenance': { + 'corrective': 'Repair', + 'software': 'Software update', + 'performance': 'Performance upgrade', + 'safety': 'Safety improvement', + 'energy_optimization': 'Energy optimization', + 'compliance': 'Compliance update', + 'other': 'Other maintenance', + }, + 'disassembly': 'Disassembly', + 'Closing the loop': { + 'R3': 'Reuse', + 'R5': 'Refurbish', + 'R6': 'Remanufacture', + 'R7': 'Repurpose', + }, + 'End-of-life treatment': { + 'recycling': 'Recycling', + 'landfill': 'Landfilling', + 'incineration': 'Incineration', + 'stockpiling': 'Stockpiling', + 'disposal': 'Disposal (unspecified)', + }, } - id = models.UUIDField(primary_key=True, editable=False) - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='service_events') - operator = models.ForeignKey(ServiceOperator, on_delete=models.CASCADE) - # life_stage = models.CharField(max_length=20, choices=LIFE_STAGES) # Obsolete, already implied by service_type - service_type = models.CharField(max_length=30, blank=True, choices=SERVICE_TYPES) + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + product = models.ForeignKey(ProductItem, on_delete=models.CASCADE, related_name='service_events') + operator = models.ForeignKey(ServiceOperator, on_delete=models.CASCADE, help_text="Entity that performs this event.") + type = models.CharField(max_length=30, choices=EVENT_TYPES) date = models.DateField(auto_now_add=True) - maintenance_plan = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL) - def clean(self): - if self.service_type in ['preventive_maintenance', 'corrective_maintenance'] and not self.maintenance_plan: - raise ValidationError("Maintenance plan must be attached for maintenance services.") + # Link to LCA activity + def get_empty_activity(): + unknown, created = ManufacturingProcess.objects.get_or_create(name="Empty activity") + return unknown + activity_data = models.ForeignKey(ManufacturingProcess, default=get_empty_activity, on_delete=models.SET_DEFAULT, help_text="Activity describing the inputs and outputs (optional).") -class ServiceRecord(models.Model): + def __str__(self): + event = self.EVENT_TYPES[self.type] + return f"{event} of a {self.product.product_batch.name}" + +class InspectionEvent(LifeCycleEvent): + description = models.TextField(max_length=300, blank=True, help_text="Diagnostic tools or methods used") + diagnostic_results = models.ManyToManyField(Document, blank=True) + +class MaintenanceEvent(LifeCycleEvent): + """Describes maintenance, repair, refurbishment, and similar events. + """ description = models.TextField(max_length=300) - service_event = models.ForeignKey(ServiceEvent, on_delete=models.CASCADE) + maintenance_plan = models.ForeignKey(Document, on_delete=models.RESTRICT) # Modifications fields - MODIFICATIONS = { - 'corrective': 'Repair', - 'software': 'Software update', - 'performance': 'Performance upgrade', - 'safety': 'Safety improvement', - 'energy_optimization': 'Energy optimization', - 'compliance': 'Compliance update', - 'other': 'Other', - } - modification_category = models.CharField(max_length=50, choices=MODIFICATIONS) - affected_functionality = models.CharField(max_length=500, blank=True) + affected_functionality = models.CharField(max_length=200, blank=True) software_or_hardware = models.BooleanField(choices={True: "Software", False: "Hardware"}) - # Repair fields (aka corrective maintenance) - root_cause = models.TextField(max_length=300, blank=True) - diagnostics_performed = models.TextField(max_length=300, blank=True) - corrective_action = models.TextField(max_length=300, blank=True) - -class ReplacedComponents(models.Model): - """ Components that were replaced or added during a service event.""" - service_record = models.ForeignKey(ServiceRecord, on_delete=models.CASCADE, related_name='replaced_components') - old_component = models.ForeignKey(Product, blank=True, null=True, on_delete=models.SET_NULL, related_name='replaced') - new_component = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='installed') - -class EndOfLife(models.Model): - service_record = models.ForeignKey(ServiceRecord, on_delete=models.CASCADE, related_name='end_of_life') - EOL_TREATMENTS = { - 'recycling': 'Recycling', - 'disposal': 'Disposal', - 'incineration': 'Incineration', - 'stockpiling': 'Stockpiling', - } - treatment_type = models.CharField(max_length=20, choices=EOL_TREATMENTS) - affected_component = models.ForeignKey(Product, on_delete=models.RESTRICT) + # Repair (i.e. corrective maintenance) fields + root_cause = models.TextField(max_length=300, blank=True, help_text="For repair only. Specify the root cause of failure.") + + def clean(self): + if self.type == 'maintenance' and not self.maintenance_plan: + raise ValidationError("Maintenance plan must be attached for maintenance services.") + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + +class DisassemblyEvent(LifeCycleEvent): + """Describes detachment of components from a ProductItem. + NOTE: self.product is the *input* being disassembled. + """ + def save(self, *args, **kwargs): + for component in self.product.disassemble(): + ItemExchange.objects.create(item=component, event=self, amount=-1) + super().save(*args, **kwargs) + + +class ItemExchange(models.Model): + """Describes where individual product/component items are used (positive) + or produced (negative values). + Can be used for component replacement, disassembly, and closing a loop. + """ + item = models.ForeignKey(ProductItem, on_delete=models.CASCADE) + event = models.ForeignKey(LifeCycleEvent, on_delete=models.CASCADE, related_name='item_exchanges') + amount = models.SmallIntegerField(help_text="Inputs are positive, outputs are negative.") + + def clean(self): + super().clean() + allowed_events = LifeCycleEvent.EVENT_TYPES['Maintenance'].keys() + LifeCycleEvent.EVENT_TYPES['Closing the loop'].keys() + ['disassembly'] + if self.event.type not in allowed_events: + raise ValidationError("This life cycle event cannot exchange items.") + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def __str__(self): + arrow = '<-' if self.amount < 0 else '->' + return f"{abs(self.amount)} {self.item} {arrow} {self.event}" ## Sustainability evaluation +class IndicatorSet(models.Model): + """A methodology or group of related impact indicators (e.g. EF3.0)""" + name = models.CharField(max_length=50) + start_date = models.DateField("Release date") + end_date = models.DateField("Phase-out date", blank=True, null=True) + + def __str__(self): + return self.name + class ImpactCategory(models.Model): + """The type of impact that is assessed""" name = models.CharField(max_length=50) + + class Meta: + verbose_name_plural = "Impact categories" + + def __str__(self): + return self.name + +class ImpactIndicator(models.Model): + """Life Cycle Impact Assessment Method or Socio-Economic indicator""" + method = models.CharField(max_length=50) description = models.CharField(max_length=200, blank=True) unit = models.CharField(max_length=40) - is_environmental = models.BooleanField(choices={True: "Environmental", False: "Socioeconomic"}, default=True) - # type = models.CharField(max_length=3, choices={'env': 'environmental', 'sec': 'socioeconomic'}) + is_environmental = models.BooleanField("Type of impact", choices={True: "Environmental", False: "Socioeconomic"}, default=True) + indicator_set = models.ForeignKey(IndicatorSet, on_delete=models.SET_NULL, blank=True, null=True) + impact_category = models.ForeignKey(ImpactCategory, on_delete=models.PROTECT) def __str__(self): - return self.name + return self.method -class SustainablityEvaluation(models.Model): # including metadata +class SustainabilityEvaluation(models.Model): """ A sustainability evaluation is defined by a scope definition, a functional unit (the final product of a production line), and its amount. """ - # FIXME: perhaps this should also have a field is_environmental, to avoid mismatches in SustainabilityScore GEO_CHOICES = { 'EU': 'European Union (EU)', 'c': 'Country-specific', @@ -499,13 +1160,14 @@ class SustainablityEvaluation(models.Model): # including metadata '-': 'Other', } def get_year(): - return datetime.date.today().year + return str(datetime.date.today().year) - product_line = models.ForeignKey(ProductionLine, on_delete=models.CASCADE) + product = models.ForeignKey(Flow, on_delete=models.CASCADE, related_name='sustainability_evaluation') + is_environmental = models.BooleanField("Whether this is an environmental sustainability evaluation (LCA).", default=True) functional_amount = models.FloatField() system_boundaries = models.CharField(max_length=200, blank=True) geographical_scope = models.CharField(max_length=4, choices=GEO_CHOICES, blank=True) - temporal_scope = models.CharField(max_length=50, default=str(get_year)) + temporal_scope = models.CharField(max_length=50, default=get_year) impact_assessment_method = models.CharField(max_length=50, blank=True, help_text="Specify the environmental impact assessment method. E.g. EF 3.0, ReCiPe, ILCD, TRACI.") software_used = models.CharField(max_length=50, blank=True, help_text="Indicate the assessment software used. E.g. OpenLCA, GaBi, SimaPro, Umberto.") allocation_method = models.CharField(max_length=6, blank=True, choices={'mass': 'Mass-based', 'econom': 'Economic (price-based)', 'energy': 'Energy-based', 'other': 'Other'}, help_text="How are impacts allocated for co-production processes?") @@ -513,23 +1175,23 @@ def get_year(): assessed_by = models.ForeignKey(Institution, blank=True, null=True, on_delete=models.PROTECT) @property - def functional_flow(self): - return self.product_line.final_product.name + def reference_flow(self): + return self.product @property def functional_unit(self): # literally the unit - return self.product_line.final_product.unit + return self.product.model.unit def __str__(self): - return f"Sustainability evaluation of {self.functional_amount} {self.functional_unit} {self.functional_flow}" + return f"Sustainability evaluation of {self.functional_amount} {self.functional_unit} {self.reference_flow}" class SustainabilityScore(models.Model): """ - The indicator results for one impact category in a SustainablityEvaluation, + The indicator results for one impact category in a SustainabilityEvaluation, plus contribution analysis data. """ - impact_category = models.ForeignKey(ImpactCategory, on_delete=models.CASCADE) - evaluation = models.ForeignKey(SustainablityEvaluation, on_delete=models.CASCADE) - impact_value = models.FloatField() # cradle-to-gate total (unit = impact_category.unit) + impact_indicator = models.ForeignKey(ImpactIndicator, on_delete=models.CASCADE) + evaluation = models.ForeignKey(SustainabilityEvaluation, on_delete=models.CASCADE, related_name='sustainability_score') + impact_value = models.FloatField() # cradle-to-gate total (unit = impact_indicator.unit) upstream_phase = models.FloatField(default=0, validators=FRACTION_VALIDATOR) manufacturing_phase = models.FloatField(default=0, validators=FRACTION_VALIDATOR) use_phase = models.FloatField(default=0, validators=FRACTION_VALIDATOR) @@ -537,129 +1199,37 @@ class SustainabilityScore(models.Model): scope_1_2_3 = models.FloatField("Scope 1+2+3 CO2 emission", help_text="Total greenhouse gas emissions associated with the product over its lifecycle, expressed as kg CO2 equivalents.") def __str__(self): - return f"{self.impact_value} {self.impact_category.unit} for {self.evaluation}" + return f"{self.impact_value} {self.impact_indicator.unit} for {self.evaluation}" + + def clean(self): + if self.impact_indicator.is_environmental != self.evaluation.is_environmental: + is_env = '' if self.evaluation.is_environmental else 'not' + raise ValidationError(f"Wrong indicator selected. The indicator must {is_env} be an environmental indicator for this evaluation.") + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) ## Circularity indicators class CircularityEvaluation(models.Model): - """A circularity evaluation of a certain ProductType.""" - product = models.ForeignKey(ProductType, on_delete=models.CASCADE) + """A circularity evaluation of a certain product model/batch.""" + product = models.ForeignKey(Flow, on_delete=models.CASCADE, related_name='circularity_evaluation') assessment_date = models.DateField(default=datetime.date.today, help_text="When the assessment was made or updated.") assessed_by = models.ForeignKey(Institution, blank=True, null=True, on_delete=models.PROTECT) - report = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='circularity_reports', help_text="Report describing the circularity assessment, and manual for monitoring and updating the circularity metrics.") - -R_CHOICES = { #TODO: move this to a database init script - 'R0 - Refuse': [ - ('hazardous', 'Hazardous substances'), - ('fossil', 'Fossil energy use'), - ('nonrenewable', 'Non-renewable materials'), - ('other', 'Other materials'), - ('consumption', 'Avoided product consumption'), - ], - 'R1 - Rethink': [ - ('modularity', 'Modularity'), - ('product_takeback', 'Product take-back'), # Appears multiple times - ('crm', 'Critical Materials'), - ('shared_use', 'Shared use'), - ('durability', 'Durability'), - ('potential_use_during_lifetime', 'Potential use during lifetime'), - ('multifunctionality', 'Multifunctionality'), - ('modularity_score', 'Modularity score'), - ('materials', 'Materials'), - ('number_of_components', 'Number of components'), - ('material_composition_complexity', 'Material composition complexity'), - ('tools_required', 'Number of tools required'), - ('separable_pieces_ratio', 'Separable pieces ratio'), - ], - 'R2 - Reduce': [ - ('reduce_raw_materials_intensity', 'Raw materials intensity reduction'), - ('reduce_energy_intensity', 'Energy intensity reduction'), - ('reduce_energy_consumption', 'Energy consumption reduction'), - ('reduce_waste_generation', 'Waste generation reduction'), - ('reduce_material_losses', 'Material losses reduction'), - ('reduce_water_intensity', 'Water intensity reduction'), - ('reduce_water_consumption', 'Water consumption'), - ], - 'R3 - Reuse': [ - ('reuse_rate', 'Reuse rate'), - ('product_takeback', 'Product take-back'), - ('consumer_awareness', 'Consumer awareness'), - ('potential_use', 'Potential use'), - ('ownership_time', 'Ownership time'), - ('voidance_of_reuse_barriers', 'Voidance of reuse rarriers'), - ('reuse_potential', 'Reuse potential'), - ('costs_of_reuse', 'Costs of reuse'), - ('access_to_parts', 'Access to high-value parts'), - ], - 'R4 - Repair': [ - ('longevity_extension', 'Longevity extension'), - ('extension_of_producer_responsibility', 'Extension of producer responsibility'), - ('consumer_awareness', 'Consumer awareness'), - ('potential_repair', 'Potential repair'), - ('repairability_score', 'Repairability score'), - ('durability_score', 'Durability score'), - ('non_destructive_disassembly_score', 'Non-destructive disassembly score'), - ('ease_of_reassembly', 'Ease of reassembly'), - ], - 'R5 - Refurbish': [ - ('product_takeback', 'Product take-back'), - ('refurbished_content', 'Refurbished content'), - ('refurbishment_potential', 'Refurbishment rotential'), - ('refurbishment_score', 'Refurbishment score'), - ('upgradability_score', 'Upgradability score'), - ], - 'R6 - Remanufacture': [ - ('product_takeback', 'Product take-back'), - ('remanufacturing_effectiveness', 'Remanufacturing effectiveness'), - ('consumer_awareness', 'Consumer awareness'), - ('remanufacturing_content', 'Remanufacturing content'), - ('remanufacturing_score', 'Remanufacturing score'), - ], - 'R7 - Repurpose': [ - ('secondary_raw_materials', 'Secondary raw materials'), - ('hazardous_waste_diverted', 'Hazardous waste diverted from disposal'), - ('nonhazardous_waste_diverted', 'Non-hazardous waste diverted from disposal'), - ], - 'R8 - Recycle': [ - ('overall_recycling_rates', 'Overall recycling rates'), - ('recycling_rate_for_waste_streams', 'Recycling rate for waste streams'), - ('waste_generation', 'Waste generation'), - ('reverse_logistics', 'Reverse logistics'), - ('recycling_potential', 'Recycling potential'), - ('design_for_recyclability', 'Design for recyclability'), - ('recycling_compatibility_score', 'Recycling compatibility score'), - ('material_homogeneity_score', 'Material homogeneity score'), - ('hazardous_substance_barrier', 'Hazardous substance barrier'), - ('high_purity_sorting_possible', 'High purity sorting possible'), - ('use_of_recyclable_materials', 'Use of easily recyclable materials'), - ('recycling_collection_rate', 'Recycling collection rate'), - ], - 'R9 - Recover': [ - ('waste_diversion_from_landfill', 'Waste diversion from landfill'), - ('potential_recovery', 'Potential recovery'), - ('hazardous_waste_disposal', 'Hazardous waste directed to disposal'), - ('nonhazardous_waste_disposal', 'Non-hazardous waste directed to disposal'), - ('energy_recoverability_benefit', 'Energy recoverability benefit'), - ('raw_materials_input', 'Raw materials input'), - ], -} + report = models.ForeignKey(Document, blank=True, null=True, on_delete=models.SET_NULL, related_name='circularity_eval', help_text="Report describing the circularity assessment, and manual for monitoring and updating the circularity metrics.") -class OldCircularityIndicator(models.Model): - product = models.ForeignKey(ProductType, on_delete=models.CASCADE) - is_static = models.BooleanField(choices={True: "Static", False: "Dynamic"}, default=True) - name = models.CharField(max_length=50, choices=R_CHOICES) - value = models.FloatField() - unit = models.CharField(max_length=20) - - def __str__(self): - return f"{self.name} for {self.product} is {self.value}" - -# Alternative implementation: class CircularityIndicator(models.Model): + """ + An indicator for measuring circularity performance, + including description and unit. + """ + id = models.CharField(max_length=6, primary_key=True) name = models.CharField(max_length=50) description = models.TextField(max_length=300, blank=True) - is_static = models.BooleanField(choices={True: "Static", False: "Dynamic"}, default=True) + is_static = models.BooleanField(choices={True: "Static", False: "Dynamic"}, + default=True) unit = models.CharField(max_length=20) def __str__(self): @@ -668,37 +1238,20 @@ def __str__(self): class CircularityScore(models.Model): evaluation = models.ForeignKey(CircularityEvaluation, on_delete=models.CASCADE) indicator = models.ForeignKey(CircularityIndicator, on_delete=models.CASCADE) - value = models.FloatField() #FIXME: validation depends on indicator.unit - modified_at = models.DateField(auto_now_add=True) + value = models.FloatField() uncertainty = models.CharField(max_length=100, blank=True) comment = models.TextField(max_length=200, blank=True) + def clean(self): + if '%' in self.indicator.unit and (self.value<0 or self.value>1): + raise ValidationError(f"Value must be between 0 and 1 for fraction indicator '{self.indicator}'.") + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + def __str__(self): return f"{self.indicator.name}: {self.value}" -# End alternative implementation - -#FIXME: a service event should not update the CircularityScore of a Product, -# but rather trigger an updated assessment of the ProductType. -# class CircularityUpdate(CircularityScore): -# service_event = models.ForeignKey(ServiceEvent, on_delete=models.SET_NULL, blank=True, null=True) -# previous_value = models.FloatField() - -# # Change verbose name of 'comment' field -# def __init__(self, *args, **kwargs): -# super().__init__(*args, **kwargs) -# comm = self._meta.get_field('comment') -# comm.verbose_name = 'Change reason' - - -class CircularityEnabler(CircularityScore): - ENABLERS = { - 'design': 'Design for circularity', - 'business_model': 'Circular business model', - 'process': 'Circular process or technology', - 'other': 'Other enabler', - } - type = models.CharField(max_length=20, choices=ENABLERS) - description = models.TextField(max_length=300, blank=True, help_text="Description and functionality") # Alternative interpretation & implementation class CircularityTracker(CircularityScore): @@ -714,15 +1267,169 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -# ## Quality and compliance - -# class QualityCompliance(models.Model): -# product = models.ForeignKey(ProductType, on_delete=models.CASCADE) -# document = models.ForeignKey(Document, on_delete=models.CASCADE) +## DPP publication +class Publisher(models.Model): + # Internal (non-editable) fields + production_line = models.OneToOneField(ProductionLine, on_delete=models.CASCADE, editable=False) + status = models.PositiveSmallIntegerField(default=0, help_text="Highest successfully completed step (1-5)", editable=False) + last_run = models.DateTimeField(auto_now=True, editable=False) + error_message = models.TextField(max_length=200, blank=True, editable=False) + + # Metadata info - to be conveyed to Metadata object + amount = models.PositiveSmallIntegerField(help_text="How many items need a DPP.") + registration_numbers = models.CharField(max_length=500, blank=True, help_text="Range of numbers (comma-separated)") + issuer = models.ForeignKey(Organization, on_delete=models.PROTECT) + reo = models.ForeignKey(Company, on_delete=models.PROTECT, verbose_name='Responsible economic operator', related_name='responsible_for', help_text="The entity bearing legal responsibility for the DPP and the product.") + version = models.CharField(max_length=10, default='1.0') + language = models.CharField(max_length=20, default='EN', help_text="Language used in descriptions") + # Data access & governance + access_link_base = models.URLField(max_length=200, blank=True, help_text="Base URL to DPP record.") + access_policy = models.URLField(max_length=200, blank=True, help_text="URL to data access terms and conditions.") + access_log_enabled = models.BooleanField(default=True) + verification_type = models.SmallIntegerField(choices={0: 'None', 1: 'Digital signature', 2: 'Third party', 3: 'Blockchain'}, default=0) + credential_format = models.CharField(max_length=50, choices={'json_ld': 'JSON-LD', 'verifialble':'Verifiable credential', 'xml': 'XML', 'other': 'Other'}) + storage_location = models.SmallIntegerField(choices={0: 'Undeclared', 1: 'On-premise server', 2: 'Commercial cloud server', 3: 'Centralized certified server', 4: 'Decentralized storage'}, default=0) + audit_trail_mechanism = models.SmallIntegerField(choices={0: 'None', 1: 'Log files', 2: 'Immutable ledger'}, default=0) + update_interval = models.CharField(max_length=2, choices={'-': 'never', 'W': 'weekly', 'M': 'monthly', 'Q': 'quarterly', 'A': 'annually', 'E': 'event_driven'}, default='-') + + STEP_NAMES = { + 0: "Not started", + 1: "Check completeness", + 2: "Aggregate manufacturing process", + 3: "Compute concentrations and components", + 4: "Create transport table", + 5: "Do Life Cycle Assessment", + } -# class Meta: -# unique_together = ('product', 'document') -# ordering = ['product', 'document'] + def check_details(self, final_product: Flow): + message = "" + if not hasattr(final_product, "details"): + message += "Warning: DPP details missing. " + if not hasattr(final_product, "properties"): + message += "Warning: Product Properties missing. " + if self.registration_numbers: + if len(self.registration_numbers.split(',')) < self.amount: + message += "Warning: Insufficient registration numbers." + + return message -# def __str__(self): -# return f"{self.document} linked to {self.product}" + def run_from_step(self, start_step: int): + """Do calculations needed to complete a DPP: + - Check for missing origins and unused outputs + - ManufacturingProcess (aggregates the ProductionLine) + - Concentration (of Substances of Concern) + - Component (replaceable components) + - Transport (distances) + - LCA calculations + Updates status and triggers consecutive steps. + + Args: + start_step (int): Step (0-5) to start + Returns: + bool: Whether the + """ + self.error_message = "" + if self.status + 1 < start_step: + start_step = self.status + 1 + else: + self.status = start_step - 1 + pl = self.production_line + + try: + # Step 1: Validation + if start_step <= 1: + input_status = pl.check_missing_origins() + output_status = pl.check_unused_outputs() + detail_status = self.check_details(pl.final_product) + if message := input_status + output_status + detail_status: + raise AssertionError(message) + else: + self.status = 1 + self.save() + + # Step 2: Aggregate + if start_step <= 2: + mp = pl.aggregate_production() + self.status = 2 + self.save() + + # Step 3: Concentrations and components + if start_step <= 3: + dpp_product = pl.final_product.model + dpp_product.add_concentrations() + dpp_product.add_components() + self.status = 3 + self.save() + + # Step 4: Transport + if start_step <= 4: + pl.create_transport() + self.status = 4 + self.save() + + # Step 5: LCA + if start_step <= 5: + result = lca.create_supply_chain_lca(pl.final_product) + self.status = 5 + self.save() + + except Exception as e: + self.error_message = f"Error at step {self.status + 1}: {str(e)}" + finally: + self.last_run = datetime.datetime.now() + self.save() + return bool(self.error_message) + + def show_status(self): + """Get human-readable status.""" + return self.STEP_NAMES.get(self.status, "Unknown") + + def can_publish(self): + """Check if all steps completed successfully.""" + return self.status == 5 and not self.error_message + + def get_registration_nrs(self): + """Unpack self.registiration_numbers or generate UUIDs""" + if self.registration_numbers: + return [nr.strip() for nr in self.registration_numbers.split(',')] + else: + return [uuid4() for i in range(self.amount)] + + def create_dpps(self): + """Create self.amount number of ProductItem and Metadata objects + """ + if not self.can_publish: + print("Please solve all issues before publishing.") + return + + # Create ProductBatch if needed + prod = self.production_line.final_product + if isinstance(prod, ProductModel): + prod = ProductBatch.objects.create(batch_number=1, model=prod) + + numbers = self.get_registration_nrs() + with transaction.atomic(): + for i, reg_nr in enumerate(numbers): + item = ProductItem.objects.create( + product_batch=prod, + serial_number='-'.join( + [str(prod.model.id), str(prod.batch_number), str(i)] + ), + ) + metadata = Metadata.objects.create( + product_item=item, + registration_number=reg_nr, + issuer=self.issuer, + reo=self.reo, + version=self.version, + language=self.language, + # Data access & governance + access_link=self.access_link_base + reg_nr, + access_policy=self.access_policy, + access_log_enabled=self.access_log_enabled, + verification_type=self.verification_type, + credential_format=self.credential_format, + storage_location=self.storage_location, + audit_trail_mechanism=self.audit_trail_mechanism, + update_interval=self.update_interval, + ) diff --git a/mysite/dpp/static/css/components.css b/mysite/dpp/static/css/components.css new file mode 100644 index 0000000..8c6c80e --- /dev/null +++ b/mysite/dpp/static/css/components.css @@ -0,0 +1,128 @@ +/* Sidbar layout */ +.sidenav { + height: 100%; + width: 140pt; + position: fixed; + z-index: 1; + top: 80px; + left: 0; + background-color: #666; + overflow-x: hidden; + padding-top: 10px; +} + +/* ────────────────────────────────────────────────────────────── + Reusable Reference List Component + Use on any
containing a heading +
    + ────────────────────────────────────────────────────────────── */ +.reference-list { + margin: 2rem 0; + padding: 1rem; + background: #f8f9fa; + border-radius: 10px; + border-left: 5px solid #0d6efd; + box-shadow: 0 2px 6px rgba(0,0,0,0.05); +} + +.reference-list > h1:first-child, +.reference-list > h2:first-child, +.reference-list > h3:first-child, +.reference-list > h4:first-child { + margin-top: 0; + margin-bottom: 1rem; + font-size: 1.5rem; + font-weight: 600; + color: #212529; +} + +.reference-list ul, +.reference-list ol { + margin: 0; + padding-left: 1.6rem; +} + +.reference-list li { + margin-bottom: 0.65rem; + line-height: 1.55; + padding-left: 0.15rem; +} + +/* Links inside the list */ +.reference-list a { + color: #0d6efd; + text-decoration: none; + font-weight: 500; + transition: all 0.2s ease; +} + +.reference-list a:hover { + color: #0a58ca; + text-decoration: underline; +} + +/* Nice bullet color (works in modern browsers) */ +.reference-list ul li::marker { + color: #276983; +} + +/* Optional subtle hover background on the whole item */ +.reference-list li:hover { + background-color: rgba(13, 110, 253, 0.04); + border-radius: 4px; + padding-left: 0.15rem; + margin-left: -0.15rem; +} + +/* Empty state styling */ +.reference-list .empty { + color: #6c757d; + font-style: italic; + margin-top: 0.5rem; +} + +/* ────────────────────────────────────────────────────────────── + Add Button + ────────────────────────────────────────────────────────────── */ +.add-button { + margin: 2rem 0; + text-align: left; +} + +.add-button .btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; /* space between icon and text */ + padding: 0.65rem 1.25rem; + font-weight: 500; + border-radius: 8px; + background-color: #00a524; + color: #ffffff; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; +} + +.add-button .btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + background-color: #014e12; +} + +/* add a “plus” icon */ +.add-button .btn::before { + content: "+"; + font-weight: 700; + font-size: 1.3em; + line-height: 1; +} + +/* If you prefer using Font Awesome or Bootstrap icons instead */ +.add-button .btn i { + font-size: 1.1em; +} + +/* Two equal columns that float next to each other */ +.column { + float: left; + width: 50%; + padding: 10px; +} \ No newline at end of file diff --git a/mysite/dpp/static/css/style.css b/mysite/dpp/static/css/style.css new file mode 100644 index 0000000..28be58c --- /dev/null +++ b/mysite/dpp/static/css/style.css @@ -0,0 +1,10 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + h1 { @apply text-3xl font-bold text-slate-800 mb-6; } + h2 { @apply text-xl font-semibold text-slate-700 mb-4; } + h3 { @apply text-lg font-medium text-slate-600 mb-3; } + h4 { @apply text-base font-medium text-slate-600 mb-2; } +} \ No newline at end of file diff --git a/mysite/dpp/static/img/L4M_logo.png b/mysite/dpp/static/img/L4M_logo.png new file mode 100644 index 0000000..088981c Binary files /dev/null and b/mysite/dpp/static/img/L4M_logo.png differ diff --git a/mysite/dpp/static/img/leaf-svgrepo-com.svg b/mysite/dpp/static/img/leaf-svgrepo-com.svg new file mode 100644 index 0000000..013d99d --- /dev/null +++ b/mysite/dpp/static/img/leaf-svgrepo-com.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mysite/dpp/static/img/package-svgrepo-com.svg b/mysite/dpp/static/img/package-svgrepo-com.svg new file mode 100644 index 0000000..dbefc0d --- /dev/null +++ b/mysite/dpp/static/img/package-svgrepo-com.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mysite/dpp/static/img/project-svgrepo-com.svg b/mysite/dpp/static/img/project-svgrepo-com.svg new file mode 100644 index 0000000..7ea13eb --- /dev/null +++ b/mysite/dpp/static/img/project-svgrepo-com.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mysite/dpp/static/img/truck-svgrepo-com.svg b/mysite/dpp/static/img/truck-svgrepo-com.svg new file mode 100644 index 0000000..b79ad55 --- /dev/null +++ b/mysite/dpp/static/img/truck-svgrepo-com.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mysite/dpp/static/img/user-svgrepo-com.svg b/mysite/dpp/static/img/user-svgrepo-com.svg new file mode 100644 index 0000000..92110de --- /dev/null +++ b/mysite/dpp/static/img/user-svgrepo-com.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mysite/dpp/templates/adminlike/404.html b/mysite/dpp/templates/adminlike/404.html new file mode 100644 index 0000000..a290b99 --- /dev/null +++ b/mysite/dpp/templates/adminlike/404.html @@ -0,0 +1,12 @@ +{% extends "adminlike/base_site.html" %} +{% load i18n %} + +{% block title %}{% translate 'Page not found' %}{% endblock %} + +{% block content %} + +

    {% translate 'Page not found' %}

    + +

    {% translate 'We’re sorry, but the requested page could not be found.' %}

    + +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/500.html b/mysite/dpp/templates/adminlike/500.html new file mode 100644 index 0000000..802761d --- /dev/null +++ b/mysite/dpp/templates/adminlike/500.html @@ -0,0 +1,17 @@ +{% extends "adminlike/base_site.html" %} +{% load i18n %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block title %}{% translate 'Server error (500)' %}{% endblock %} + +{% block content %} +

    {% translate 'Server Error (500)' %}

    +

    {% translate 'There’s been an error. It’s been reported to the site administrators via email and should be fixed shortly. Thanks for your patience.' %}

    + +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/actions.html b/mysite/dpp/templates/adminlike/actions.html new file mode 100644 index 0000000..f506c92 --- /dev/null +++ b/mysite/dpp/templates/adminlike/actions.html @@ -0,0 +1,23 @@ +{% load i18n %} +
    + {% block actions %} + {% block actions-form %} + {% for field in action_form %}{% if field.label %}{% else %}{{ field }}{% endif %}{% endfor %} + {% endblock %} + {% block actions-submit %} + + {% endblock %} + {% block actions-counter %} + {% if actions_selection_counter %} + {{ selection_note }} + {% if cl.result_count != cl.result_list|length %} + + + + {% endif %} + {% endif %} + {% endblock %} + {% endblock %} +
    diff --git a/mysite/dpp/templates/adminlike/app_index.html b/mysite/dpp/templates/adminlike/app_index.html new file mode 100644 index 0000000..2dc2d8f --- /dev/null +++ b/mysite/dpp/templates/adminlike/app_index.html @@ -0,0 +1,20 @@ +{% extends "adminlike/index.html" %} +{% load i18n %} + +{% block bodyclass %}{{ block.super }} app-{{ app_label }}{% endblock %} + +{% if not is_popup %} +{% block nav-breadcrumbs %} + +{% endblock %} +{% endif %} + +{% block sidebar %}{% endblock %} diff --git a/mysite/dpp/templates/adminlike/app_list.html b/mysite/dpp/templates/adminlike/app_list.html new file mode 100644 index 0000000..60d874b --- /dev/null +++ b/mysite/dpp/templates/adminlike/app_list.html @@ -0,0 +1,51 @@ +{% load i18n %} + +{% if app_list %} + {% for app in app_list %} +
    + + + + + + + + + + {% for model in app.models %} + {% with model_name=model.object_name|lower %} + + + + {% if model.add_url %} + + {% else %} + + {% endif %} + + {% if model.admin_url and show_changelinks %} + {% if model.view_only %} + + {% else %} + + {% endif %} + {% elif show_changelinks %} + + {% endif %} + + {% endwith %} + {% endfor %} +
    + {{ app.name }} +
    {% translate 'Model name' %}{% translate 'Add link' %}{% translate 'Change or view list link' %}
    + {% if model.admin_url %} + {{ model.name }} + {% else %} + {{ model.name }} + {% endif %} + {% translate 'Add' %}{% translate 'View' %}{% translate 'Change' %}
    +
    + {% endfor %} +{% else %} +

    {% translate 'You don’t have permission to view or edit anything.' %}

    +{% endif %} diff --git a/mysite/dpp/templates/adminlike/auth/user/add_form.html b/mysite/dpp/templates/adminlike/auth/user/add_form.html new file mode 100644 index 0000000..9ac8ade --- /dev/null +++ b/mysite/dpp/templates/adminlike/auth/user/add_form.html @@ -0,0 +1,16 @@ +{% extends "adminlike/change_form.html" %} +{% load i18n static %} + +{% block form_top %} + {% if not is_popup %} +

    {% translate "After you’ve created a user, you’ll be able to edit more user options." %}

    + {% endif %} +{% endblock %} +{% block extrahead %} + {{ block.super }} + +{% endblock %} +{% block admin_change_form_document_ready %} + {{ block.super }} + +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/auth/user/change_password.html b/mysite/dpp/templates/adminlike/auth/user/change_password.html new file mode 100644 index 0000000..4cf134a --- /dev/null +++ b/mysite/dpp/templates/adminlike/auth/user/change_password.html @@ -0,0 +1,81 @@ +{% extends "adminlike/base_site.html" %} +{% load i18n static %} +{% load admin_urls %} + +{% block title %}{% if form.errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %} +{% block extrastyle %} + {{ block.super }} + + +{% endblock %} +{% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %} +{% if not is_popup %} +{% block breadcrumbs %} + +{% endblock %} +{% endif %} +{% block content %}
    +{% csrf_token %}{% block form_top %}{% endblock %} + +
    +{% if is_popup %}{% endif %} +{% if form.errors %} +

    + {% blocktranslate count counter=form.errors.items|length %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktranslate %} +

    +{% endif %} + +

    {% blocktranslate with username=original %}Enter a new password for the user {{ username }}.{% endblocktranslate %}

    +{% if not form.user.has_usable_password %} +

    {% blocktranslate %}This action will enable password-based authentication for this user.{% endblocktranslate %}

    +{% endif %} + +
    + +
    + {{ form.usable_password.errors }} +
    {{ form.usable_password.label_tag }} {{ form.usable_password }}
    + {% if form.usable_password.help_text %} +
    +

    {{ form.usable_password.help_text|safe }}

    +
    + {% endif %} +
    + +
    + {{ form.password1.errors }} +
    {{ form.password1.label_tag }} {{ form.password1 }}
    + {% if form.password1.help_text %} +
    {{ form.password1.help_text|safe }}
    + {% endif %} +
    + +
    + {{ form.password2.errors }} +
    {{ form.password2.label_tag }} {{ form.password2 }}
    + {% if form.password2.help_text %} +
    {{ form.password2.help_text|safe }}
    + {% endif %} +
    + +
    + +
    + {% if form.user.has_usable_password %} + + + {% else %} + + {% endif %} +
    + +
    +
    + +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/base.html b/mysite/dpp/templates/adminlike/base.html new file mode 100644 index 0000000..30d6f33 --- /dev/null +++ b/mysite/dpp/templates/adminlike/base.html @@ -0,0 +1,90 @@ +{% load static %} + + + + + + + {% block title %}{{ title|default:"My App" }} — My App{% endblock %} + + {% block extrastyle %} + + + + + {% endblock %} + + {% block extrahead %}{% endblock %} + + + +{% block header %} + +{% endblock %} + +
    +
    + + {% block breadcrumbs %} + + {% endblock %} + +

    {% block pagetitle %}{{ title }}{% endblock %}

    + + {% if messages %} + {% for message in messages %} +
    {{ message }}
    + {% endfor %} + {% endif %} + + {% block content %} + {{ block.super }} + {% endblock %} +
    +
    + +{% block footer %} + +{% endblock %} + +{% block extrajs %} + + + + + + + +{% endblock %} + + + \ No newline at end of file diff --git a/mysite/dpp/templates/adminlike/base_backup.html b/mysite/dpp/templates/adminlike/base_backup.html new file mode 100644 index 0000000..115894b --- /dev/null +++ b/mysite/dpp/templates/adminlike/base_backup.html @@ -0,0 +1,126 @@ +{% load i18n static %} +{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} + + +{% block title %}{% endblock %} + +{% block dark-mode-vars %} + + +{% endblock %} +{% if not is_popup and is_nav_sidebar_enabled %} + + +{% endif %} +{% block extrastyle %}{% endblock %} +{% if LANGUAGE_BIDI %}{% endif %} +{% block extrahead %}{% endblock %} +{% block responsive %} + + + {% if LANGUAGE_BIDI %}{% endif %} +{% endblock %} +{% block blockbots %}{% endblock %} + + + +{% translate 'Skip to main content' %} + +
    + + {% if not is_popup %} + + {% block header %} + + {% endblock %} + + {% block nav-breadcrumbs %} + + {% endblock %} + {% endif %} + +
    + {% if not is_popup and is_nav_sidebar_enabled %} + {% block nav-sidebar %} + {% include "admin/nav_sidebar.html" %} + {% endblock %} + {% endif %} +
    + {% block messages %} + {% if messages %} +
      {% for message in messages %} + {{ message|capfirst }} + {% endfor %}
    + {% endif %} + {% endblock messages %} + +
    + {% block pretitle %}{% endblock %} + {% block content_title %}{% if title %}

    {{ title }}

    {% endif %}{% endblock %} + {% block content_subtitle %}{% if subtitle %}

    {{ subtitle }}

    {% endif %}{% endblock %} + {% block content %} + {% block object-tools %}{% endblock %} + {{ content }} + {% endblock %} + {% block sidebar %}{% endblock %} +
    +
    + +
    +
    +
    {% block footer %}{% endblock %}
    +
    + + + + + + + + + +{% block extrabody %}{% endblock extrabody %} + + diff --git a/mysite/dpp/templates/adminlike/base_site.html b/mysite/dpp/templates/adminlike/base_site.html new file mode 100644 index 0000000..213e5d8 --- /dev/null +++ b/mysite/dpp/templates/adminlike/base_site.html @@ -0,0 +1,12 @@ +{% extends "adminlike/base.html" %} + +{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block branding %} + +{% if user.is_anonymous %} + {% include "admin/color_theme_toggle.html" %} +{% endif %} +{% endblock %} + +{% block nav-global %}{% endblock %} diff --git a/mysite/dpp/templates/adminlike/change_form.html b/mysite/dpp/templates/adminlike/change_form.html new file mode 100644 index 0000000..dd8db9c --- /dev/null +++ b/mysite/dpp/templates/adminlike/change_form.html @@ -0,0 +1,82 @@ +{% extends "adminlike/base_site.html" %} +{% load i18n admin_urls static admin_modify %} + +{% block title %}{% if errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %} +{% block extrahead %}{{ block.super }} + +{{ media }} +{% endblock %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block coltype %}colM{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-form{% endblock %} + +{% if not is_popup %} +{% block breadcrumbs %} + +{% endblock %} +{% endif %} + +{% block content %}
    +{% block object-tools %} +{% if change and not is_popup %} +
      + {% block object-tools-items %} + {% change_form_object_tools %} + {% endblock %} +
    +{% endif %} +{% endblock %} +
    {% csrf_token %}{% block form_top %}{% endblock %} +
    +{% if is_popup %}{% endif %} +{% if to_field %}{% endif %} +{% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %} +{% if errors %} +

    + {% blocktranslate count counter=errors|length %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktranslate %} +

    + {{ adminform.form.non_field_errors }} +{% endif %} + +{% block field_sets %} +{% for fieldset in adminform %} + {% include "admin/includes/fieldset.html" with heading_level=2 prefix="fieldset" id_prefix=0 id_suffix=forloop.counter0 %} +{% endfor %} +{% endblock %} + +{% block after_field_sets %}{% endblock %} + +{% block inline_field_sets %} +{% for inline_admin_formset in inline_admin_formsets %} + {% include inline_admin_formset.opts.template %} +{% endfor %} +{% endblock %} + +{% block after_related_objects %}{% endblock %} + +{% block submit_buttons_bottom %}{% submit_row %}{% endblock %} + +{% block admin_change_form_document_ready %} + +{% endblock %} + +{# JavaScript for prepopulated fields #} +{% prepopulated_fields_js %} + +
    +
    +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/change_form_object_tools.html b/mysite/dpp/templates/adminlike/change_form_object_tools.html new file mode 100644 index 0000000..067ae83 --- /dev/null +++ b/mysite/dpp/templates/adminlike/change_form_object_tools.html @@ -0,0 +1,8 @@ +{% load i18n admin_urls %} +{% block object-tools-items %} +
  • + {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} + {% translate "History" %} +
  • +{% if has_absolute_url %}
  • {% translate "View on site" %}
  • {% endif %} +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/change_list.html b/mysite/dpp/templates/adminlike/change_list.html new file mode 100644 index 0000000..27d4b91 --- /dev/null +++ b/mysite/dpp/templates/adminlike/change_list.html @@ -0,0 +1,94 @@ +{% extends "adminlike/base_site.html" %} +{% load i18n admin_urls static admin_list %} + +{% block title %}{% if cl.formset and cl.formset.errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %} +{% block extrastyle %} + {{ block.super }} + + {% if cl.formset %} + + {% endif %} + {% if cl.formset or action_form %} + + {% endif %} + {{ media.css }} + {% if not actions_on_top and not actions_on_bottom %} + + {% endif %} +{% endblock %} + +{% block extrahead %} +{{ block.super }} +{{ media.js }} + +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %} + +{% if not is_popup %} +{% block breadcrumbs %} + +{% endblock %} +{% endif %} + +{% block coltype %}{% endblock %} + +{% block content %} +
    + {% block object-tools %} +
      + {% block object-tools-items %} + {% change_list_object_tools %} + {% endblock %} +
    + {% endblock %} + {% if cl.formset and cl.formset.errors %} +

    + {% blocktranslate count counter=cl.formset.total_error_count %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktranslate %} +

    + {{ cl.formset.non_form_errors }} + {% endif %} +
    +
    + {% block search %}{% search_form cl %}{% endblock %} + {% block date_hierarchy %}{% if cl.date_hierarchy %}{% date_hierarchy cl %}{% endif %}{% endblock %} + +
    {% csrf_token %} + {% if cl.formset %} +
    {{ cl.formset.management_form }}
    + {% endif %} + + {% block result_list %} + {% if action_form and actions_on_top and cl.show_admin_actions %}{% admin_actions %}{% endif %} + {% result_list cl %} + {% if action_form and actions_on_bottom and cl.show_admin_actions %}{% admin_actions %}{% endif %} + {% endblock %} + {% block pagination %}{% pagination cl %}{% endblock %} +
    +
    + {% block filters %} + {% if cl.has_filters %} + + {% endif %} + {% endblock %} +
    +
    +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/change_list_object_tools.html b/mysite/dpp/templates/adminlike/change_list_object_tools.html new file mode 100644 index 0000000..11cc6fa --- /dev/null +++ b/mysite/dpp/templates/adminlike/change_list_object_tools.html @@ -0,0 +1,12 @@ +{% load i18n admin_urls %} + +{% block object-tools-items %} + {% if has_add_permission %} +
  • + {% url cl.opts|admin_urlname:'add' as add_url %} + + {% blocktranslate with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktranslate %} + +
  • + {% endif %} +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/change_list_results.html b/mysite/dpp/templates/adminlike/change_list_results.html new file mode 100644 index 0000000..d2a9fee --- /dev/null +++ b/mysite/dpp/templates/adminlike/change_list_results.html @@ -0,0 +1,36 @@ +{% load i18n %} +{% if result_hidden_fields %} +
    {# DIV for HTML validation #} +{% for item in result_hidden_fields %}{{ item }}{% endfor %} +
    +{% endif %} +{% if results %} +
    + + + +{% for header in result_headers %} +{% endfor %} + + + +{% for result in results %} +{% if result.form and result.form.non_field_errors %} + +{% endif %} +{% for item in result %}{{ item }}{% endfor %} +{% endfor %} + +
    + {% if header.sortable and header.sort_priority > 0 %} +
    + + {% if num_sorted_fields > 1 %}{{ header.sort_priority }}{% endif %} + +
    + {% endif %} +
    {% if header.sortable %}{{ header.text|capfirst }}{% else %}{{ header.text|capfirst }}{% endif %}
    +
    +
    {{ result.form.non_field_errors }}
    +
    +{% endif %} diff --git a/mysite/dpp/templates/adminlike/color_theme_toggle.html b/mysite/dpp/templates/adminlike/color_theme_toggle.html new file mode 100644 index 0000000..2caa19e --- /dev/null +++ b/mysite/dpp/templates/adminlike/color_theme_toggle.html @@ -0,0 +1,15 @@ +{% load i18n %} + diff --git a/mysite/dpp/templates/adminlike/date_hierarchy.html b/mysite/dpp/templates/adminlike/date_hierarchy.html new file mode 100644 index 0000000..c508856 --- /dev/null +++ b/mysite/dpp/templates/adminlike/date_hierarchy.html @@ -0,0 +1,14 @@ +{% if show %} + +{% endif %} diff --git a/mysite/dpp/templates/adminlike/delete_confirmation.html b/mysite/dpp/templates/adminlike/delete_confirmation.html new file mode 100644 index 0000000..a4e4f7a --- /dev/null +++ b/mysite/dpp/templates/adminlike/delete_confirmation.html @@ -0,0 +1,50 @@ +{% extends "adminlike/base_site.html" %} +{% load i18n admin_urls static %} + +{% block extrahead %} + {{ block.super }} + {{ media }} + +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +{% if perms_lacking %} + {% block delete_forbidden %} +

    {% blocktranslate with escaped_object=object %}Deleting the {{ object_name }} '{{ escaped_object }}' would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}

    +
      {{ perms_lacking|unordered_list }}
    + {% endblock %} +{% elif protected %} + {% block delete_protected %} +

    {% blocktranslate with escaped_object=object %}Deleting the {{ object_name }} '{{ escaped_object }}' would require deleting the following protected related objects:{% endblocktranslate %}

    +
      {{ protected|unordered_list }}
    + {% endblock %} +{% else %} + {% block delete_confirm %} +

    {% blocktranslate with escaped_object=object %}Are you sure you want to delete the {{ object_name }} "{{ escaped_object }}"? All of the following related items will be deleted:{% endblocktranslate %}

    + {% include "admin/includes/object_delete_summary.html" %} +

    {% translate "Objects" %}

    +
      {{ deleted_objects|unordered_list }}
    +
    {% csrf_token %} +
    + + {% if is_popup %}{% endif %} + {% if to_field %}{% endif %} + + {% translate "No, take me back" %} +
    +
    + {% endblock %} +{% endif %} +{% endblock content %} diff --git a/mysite/dpp/templates/adminlike/delete_selected_confirmation.html b/mysite/dpp/templates/adminlike/delete_selected_confirmation.html new file mode 100644 index 0000000..1b4a040 --- /dev/null +++ b/mysite/dpp/templates/adminlike/delete_selected_confirmation.html @@ -0,0 +1,47 @@ +{% extends "adminlike/base_site.html" %} +{% load i18n l10n admin_urls static %} + +{% block extrahead %} + {{ block.super }} + {{ media }} + +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation delete-selected-confirmation{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +{% if perms_lacking %} +

    {% blocktranslate %}Deleting the selected {{ objects_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}

    +
      {{ perms_lacking|unordered_list }}
    +{% elif protected %} +

    {% blocktranslate %}Deleting the selected {{ objects_name }} would require deleting the following protected related objects:{% endblocktranslate %}

    +
      {{ protected|unordered_list }}
    +{% else %} +

    {% blocktranslate %}Are you sure you want to delete the selected {{ objects_name }}? All of the following objects and their related items will be deleted:{% endblocktranslate %}

    + {% include "admin/includes/object_delete_summary.html" %} +

    {% translate "Objects" %}

    + {% for deletable_object in deletable_objects %} +
      {{ deletable_object|unordered_list }}
    + {% endfor %} +
    {% csrf_token %} +
    + {% for obj in queryset %} + + {% endfor %} + + + + {% translate "No, take me back" %} +
    +
    +{% endif %} +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/edit_inline/stacked.html b/mysite/dpp/templates/adminlike/edit_inline/stacked.html new file mode 100644 index 0000000..a6939f4 --- /dev/null +++ b/mysite/dpp/templates/adminlike/edit_inline/stacked.html @@ -0,0 +1,38 @@ +{% load i18n admin_urls %} +
    +
    + {% if inline_admin_formset.is_collapsible %}
    {% endif %} +

    + {% if inline_admin_formset.formset.max_num == 1 %} + {{ inline_admin_formset.opts.verbose_name|capfirst }} + {% else %} + {{ inline_admin_formset.opts.verbose_name_plural|capfirst }} + {% endif %} +

    + {% if inline_admin_formset.is_collapsible %}
    {% endif %} +{{ inline_admin_formset.formset.management_form }} +{{ inline_admin_formset.formset.non_form_errors }} + +{% for inline_admin_form in inline_admin_formset %}
    +

    {{ inline_admin_formset.opts.verbose_name|capfirst }}: {% if inline_admin_form.original %}{{ inline_admin_form.original }}{% if inline_admin_form.model_admin.show_change_link and inline_admin_form.model_admin.has_registered_model %} {% if inline_admin_formset.has_change_permission %}{% translate "Change" %}{% else %}{% translate "View" %}{% endif %}{% endif %} +{% else %}#{{ forloop.counter }}{% endif %} + {% if inline_admin_form.show_url %}{% translate "View on site" %}{% endif %} + {% if inline_admin_formset.formset.can_delete and inline_admin_formset.has_delete_permission and inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}{% endif %} +

    + {% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %} + + {% with parent_counter=forloop.counter0 %} + {% for fieldset in inline_admin_form %} + {% include "admin/includes/fieldset.html" with heading_level=4 prefix=fieldset.formset.prefix id_prefix=parent_counter id_suffix=forloop.counter0 %} + {% endfor %} + {% endwith %} + + {% if inline_admin_form.needs_explicit_pk_field %}{{ inline_admin_form.pk_field.field }}{% endif %} + {% if inline_admin_form.fk_field %}{{ inline_admin_form.fk_field.field }}{% endif %} +
    {% endfor %} + {% if inline_admin_formset.is_collapsible %}
    {% endif %} +
    +
    diff --git a/mysite/dpp/templates/adminlike/edit_inline/tabular.html b/mysite/dpp/templates/adminlike/edit_inline/tabular.html new file mode 100644 index 0000000..9367ac9 --- /dev/null +++ b/mysite/dpp/templates/adminlike/edit_inline/tabular.html @@ -0,0 +1,69 @@ +{% load i18n admin_urls static admin_modify %} +
    + +
    diff --git a/mysite/dpp/templates/adminlike/filter.html b/mysite/dpp/templates/adminlike/filter.html new file mode 100644 index 0000000..a6094ec --- /dev/null +++ b/mysite/dpp/templates/adminlike/filter.html @@ -0,0 +1,12 @@ +{% load i18n %} +
    + + {% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %} + + +
    diff --git a/mysite/dpp/templates/adminlike/includes/fieldset.html b/mysite/dpp/templates/adminlike/includes/fieldset.html new file mode 100644 index 0000000..9c9b319 --- /dev/null +++ b/mysite/dpp/templates/adminlike/includes/fieldset.html @@ -0,0 +1,39 @@ +
    + {% if fieldset.name %} + {% if fieldset.is_collapsible %}
    {% endif %} + {{ fieldset.name }} + {% if fieldset.is_collapsible %}{% endif %} + {% endif %} + {% if fieldset.description %} +
    {{ fieldset.description|safe }}
    + {% endif %} + {% for line in fieldset %} +
    + {% if line.fields|length == 1 %}{{ line.errors }}{% else %}
    {% endif %} + {% for field in line %} +
    + {% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %} +
    + {% if field.is_checkbox %} + {{ field.field }}{{ field.label_tag }} + {% else %} + {{ field.label_tag }} + {% if field.is_readonly %} +
    {{ field.contents }}
    + {% else %} + {{ field.field }} + {% endif %} + {% endif %} +
    + {% if field.field.help_text %} +
    +
    {{ field.field.help_text|safe }}
    +
    + {% endif %} +
    + {% endfor %} + {% if not line.fields|length == 1 %}
    {% endif %} +
    + {% endfor %} + {% if fieldset.name and fieldset.is_collapsible %}
    {% endif %} +
    diff --git a/mysite/dpp/templates/adminlike/includes/object_delete_summary.html b/mysite/dpp/templates/adminlike/includes/object_delete_summary.html new file mode 100644 index 0000000..9ad97db --- /dev/null +++ b/mysite/dpp/templates/adminlike/includes/object_delete_summary.html @@ -0,0 +1,7 @@ +{% load i18n %} +

    {% translate "Summary" %}

    +
      + {% for model_name, object_count in model_count %} +
    • {{ model_name|capfirst }}: {{ object_count }}
    • + {% endfor %} +
    diff --git a/mysite/dpp/templates/adminlike/index.html b/mysite/dpp/templates/adminlike/index.html new file mode 100644 index 0000000..99d3836 --- /dev/null +++ b/mysite/dpp/templates/adminlike/index.html @@ -0,0 +1,51 @@ +{% extends "adminlike/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block coltype %}colMS{% endblock %} + +{% block bodyclass %}{{ block.super }} dashboard{% endblock %} + +{% block nav-breadcrumbs %}{% endblock %} + +{% block nav-sidebar %}{% endblock %} + +{% block content %} +
    + {% include "admin/app_list.html" with app_list=app_list show_changelinks=True %} +
    +{% endblock %} + +{% block sidebar %} + +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/invalid_setup.html b/mysite/dpp/templates/adminlike/invalid_setup.html new file mode 100644 index 0000000..2467457 --- /dev/null +++ b/mysite/dpp/templates/adminlike/invalid_setup.html @@ -0,0 +1,13 @@ +{% extends "adminlike/base_site.html" %} +{% load i18n %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

    {% translate 'Something’s wrong with your database installation. Make sure the appropriate database tables have been created, and make sure the database is readable by the appropriate user.' %}

    +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/login.html b/mysite/dpp/templates/adminlike/login.html new file mode 100644 index 0000000..e01e2de --- /dev/null +++ b/mysite/dpp/templates/adminlike/login.html @@ -0,0 +1,69 @@ +{% extends "adminlike/base_site.html" %} +{% load i18n static %} + +{% block title %}{% if form.errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %} +{% block extrastyle %}{{ block.super }} +{{ form.media }} +{% endblock %} + +{% block bodyclass %}{{ block.super }} login{% endblock %} + +{% block usertools %}{% endblock %} + +{% block nav-global %}{% endblock %} + +{% block nav-sidebar %}{% endblock %} + +{% block content_title %}{% endblock %} + +{% block nav-breadcrumbs %}{% endblock %} + +{% block content %} +{% if form.errors and not form.non_field_errors %} +

    +{% blocktranslate count counter=form.errors.items|length %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktranslate %} +

    +{% endif %} + +{% if form.non_field_errors %} +{% for error in form.non_field_errors %} +

    + {{ error }} +

    +{% endfor %} +{% endif %} + +
    + +{% if user.is_authenticated %} +

    +{% blocktranslate trimmed %} + You are authenticated as {{ username }}, but are not authorized to + access this page. Would you like to login to a different account? +{% endblocktranslate %} +

    +{% endif %} + +
    {% csrf_token %} +
    + {{ form.username.errors }} + {{ form.username.label_tag }} {{ form.username }} +
    +
    + {{ form.password.errors }} + {{ form.password.label_tag }} {{ form.password }} + +
    + {% url 'admin_password_reset' as password_reset_url %} + {% if password_reset_url %} + + {% endif %} +
    + +
    +
    + +
    +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/nav_sidebar.html b/mysite/dpp/templates/adminlike/nav_sidebar.html new file mode 100644 index 0000000..a413e23 --- /dev/null +++ b/mysite/dpp/templates/adminlike/nav_sidebar.html @@ -0,0 +1,8 @@ +{% load i18n %} + + diff --git a/mysite/dpp/templates/adminlike/object_history.html b/mysite/dpp/templates/adminlike/object_history.html new file mode 100644 index 0000000..7bd4f80 --- /dev/null +++ b/mysite/dpp/templates/adminlike/object_history.html @@ -0,0 +1,56 @@ +{% extends "adminlike/base_site.html" %} +{% load i18n admin_urls %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    + +{% if action_list %} + + + + + + + + + + {% for action in action_list %} + + + + + + {% endfor %} + +
    {% translate 'Date/time' %}{% translate 'User' %}{% translate 'Action' %}
    {{ action.action_time|date:"DATETIME_FORMAT" }}{{ action.user.get_username }}{% if action.user.get_full_name %} ({{ action.user.get_full_name }}){% endif %}{{ action.get_change_message }}
    +

    + {% if pagination_required %} + {% for i in page_range %} + {% if i == action_list.paginator.ELLIPSIS %} + {{ action_list.paginator.ELLIPSIS }} + {% elif i == action_list.number %} + {{ i }} + {% else %} + {{ i }} + {% endif %} + {% endfor %} + {% endif %} + {{ action_list.paginator.count }} {% blocktranslate count counter=action_list.paginator.count %}entry{% plural %}entries{% endblocktranslate %} +

    +{% else %} +

    {% translate 'This object doesn’t have a change history. It probably wasn’t added via this admin site.' %}

    +{% endif %} +
    +
    +{% endblock %} diff --git a/mysite/dpp/templates/adminlike/pagination.html b/mysite/dpp/templates/adminlike/pagination.html new file mode 100644 index 0000000..bc3117b --- /dev/null +++ b/mysite/dpp/templates/adminlike/pagination.html @@ -0,0 +1,12 @@ +{% load admin_list %} +{% load i18n %} +

    +{% if pagination_required %} +{% for i in page_range %} + {% paginator_number cl i %} +{% endfor %} +{% endif %} +{{ cl.result_count }} {% if cl.result_count == 1 %}{{ cl.opts.verbose_name }}{% else %}{{ cl.opts.verbose_name_plural }}{% endif %} +{% if show_all_url %}{% translate 'Show all' %}{% endif %} +{% if cl.formset and cl.result_count %}{% endif %} +

    diff --git a/mysite/dpp/templates/adminlike/popup_response.html b/mysite/dpp/templates/adminlike/popup_response.html new file mode 100644 index 0000000..57a1ae3 --- /dev/null +++ b/mysite/dpp/templates/adminlike/popup_response.html @@ -0,0 +1,10 @@ +{% load i18n static %} + + {% translate 'Popup closing…' %} + + + + diff --git a/mysite/dpp/templates/adminlike/prepopulated_fields_js.html b/mysite/dpp/templates/adminlike/prepopulated_fields_js.html new file mode 100644 index 0000000..dd6e561 --- /dev/null +++ b/mysite/dpp/templates/adminlike/prepopulated_fields_js.html @@ -0,0 +1,5 @@ +{% load static %} + diff --git a/mysite/dpp/templates/adminlike/search_form.html b/mysite/dpp/templates/adminlike/search_form.html new file mode 100644 index 0000000..447b803 --- /dev/null +++ b/mysite/dpp/templates/adminlike/search_form.html @@ -0,0 +1,20 @@ +{% load i18n static %} +{% if cl.search_fields %} +
    +{% endif %} diff --git a/mysite/dpp/templates/adminlike/submit_line.html b/mysite/dpp/templates/adminlike/submit_line.html new file mode 100644 index 0000000..b2b2054 --- /dev/null +++ b/mysite/dpp/templates/adminlike/submit_line.html @@ -0,0 +1,17 @@ +{% load i18n admin_urls %} +
    +{% block submit-row %} +{% if show_save %}{% endif %} +{% if show_save_as_new %}{% endif %} +{% if show_save_and_add_another %}{% endif %} +{% if show_save_and_continue %}{% endif %} +{% if show_close %} + {% url opts|admin_urlname:'changelist' as changelist_url %} + {% translate 'Close' %} +{% endif %} +{% if show_delete_link and original %} + {% url opts|admin_urlname:'delete' original.pk|admin_urlquote as delete_url %} + {% translate "Delete" %} +{% endif %} +{% endblock %} +
    diff --git a/mysite/dpp/templates/adminlike/widgets/clearable_file_input.html b/mysite/dpp/templates/adminlike/widgets/clearable_file_input.html new file mode 100644 index 0000000..8b42f19 --- /dev/null +++ b/mysite/dpp/templates/adminlike/widgets/clearable_file_input.html @@ -0,0 +1,6 @@ +{% if widget.is_initial %}

    {{ widget.initial_text }}: {{ widget.value }}{% if not widget.required %} + + +{% endif %}
    +{{ widget.input_text }}:{% endif %} +{% if widget.is_initial %}

    {% endif %} diff --git a/mysite/dpp/templates/adminlike/widgets/date.html b/mysite/dpp/templates/adminlike/widgets/date.html new file mode 100644 index 0000000..acd5d5f --- /dev/null +++ b/mysite/dpp/templates/adminlike/widgets/date.html @@ -0,0 +1,3 @@ +

    + {% include "django/forms/widgets/date.html" %} +

    diff --git a/mysite/dpp/templates/adminlike/widgets/foreign_key_raw_id.html b/mysite/dpp/templates/adminlike/widgets/foreign_key_raw_id.html new file mode 100644 index 0000000..a6eba93 --- /dev/null +++ b/mysite/dpp/templates/adminlike/widgets/foreign_key_raw_id.html @@ -0,0 +1,2 @@ +{% if related_url %}
    {% endif %}{% include 'django/forms/widgets/input.html' %}{% if related_url %}{% endif %}{% if link_label %} +{% if link_url %}{{ link_label }}{% else %}{{ link_label }}{% endif %}{% endif %}{% if related_url %}
    {% endif %} diff --git a/mysite/dpp/templates/adminlike/widgets/many_to_many_raw_id.html b/mysite/dpp/templates/adminlike/widgets/many_to_many_raw_id.html new file mode 100644 index 0000000..0dd0331 --- /dev/null +++ b/mysite/dpp/templates/adminlike/widgets/many_to_many_raw_id.html @@ -0,0 +1 @@ +{% include 'admin/widgets/foreign_key_raw_id.html' %} diff --git a/mysite/dpp/templates/adminlike/widgets/radio.html b/mysite/dpp/templates/adminlike/widgets/radio.html new file mode 100644 index 0000000..780899a --- /dev/null +++ b/mysite/dpp/templates/adminlike/widgets/radio.html @@ -0,0 +1 @@ +{% include "django/forms/widgets/multiple_input.html" %} diff --git a/mysite/dpp/templates/adminlike/widgets/related_widget_wrapper.html b/mysite/dpp/templates/adminlike/widgets/related_widget_wrapper.html new file mode 100644 index 0000000..9251527 --- /dev/null +++ b/mysite/dpp/templates/adminlike/widgets/related_widget_wrapper.html @@ -0,0 +1,41 @@ +{% load i18n static %} + diff --git a/mysite/dpp/templates/adminlike/widgets/split_datetime.html b/mysite/dpp/templates/adminlike/widgets/split_datetime.html new file mode 100644 index 0000000..7fc7bf6 --- /dev/null +++ b/mysite/dpp/templates/adminlike/widgets/split_datetime.html @@ -0,0 +1,4 @@ +

    + {{ date_label }} {% with widget=widget.subwidgets.0 %}{% include widget.template_name %}{% endwith %}
    + {{ time_label }} {% with widget=widget.subwidgets.1 %}{% include widget.template_name %}{% endwith %} +

    diff --git a/mysite/dpp/templates/adminlike/widgets/time.html b/mysite/dpp/templates/adminlike/widgets/time.html new file mode 100644 index 0000000..e4eaca6 --- /dev/null +++ b/mysite/dpp/templates/adminlike/widgets/time.html @@ -0,0 +1,3 @@ +

    + {% include "django/forms/widgets/time.html" %} +

    diff --git a/mysite/dpp/templates/adminlike/widgets/url.html b/mysite/dpp/templates/adminlike/widgets/url.html new file mode 100644 index 0000000..69dc401 --- /dev/null +++ b/mysite/dpp/templates/adminlike/widgets/url.html @@ -0,0 +1 @@ +{% if url_valid %}

    {{ current_label }} {{ widget.value }}
    {{ change_label }} {% endif %}{% include "django/forms/widgets/input.html" %}{% if url_valid %}

    {% endif %} diff --git a/mysite/dpp/templates/dpp/base.html b/mysite/dpp/templates/dpp/base.html new file mode 100644 index 0000000..162c8f7 --- /dev/null +++ b/mysite/dpp/templates/dpp/base.html @@ -0,0 +1,137 @@ +{% load static %} +{% load i18n %} + + + + + + + + + {% block title %}DPP{% endblock %} + + {% block extra_js %} + {% endblock %} + + + {% if not is_popup %} + {% block header %} + + {% endblock %} + + + + + {% block nav-breadcrumbs %} + + {% endblock %} + {% endif %} + +
    + + + {% if not is_popup %} + + {% endif %} + +
    + {% block content %}{% endblock %} +
    +
    + +{% block footer %} + +{% endblock %} + + + \ No newline at end of file diff --git a/mysite/dpp/templates/dpp/confirm_delete.html b/mysite/dpp/templates/dpp/confirm_delete.html new file mode 100644 index 0000000..e406a6a --- /dev/null +++ b/mysite/dpp/templates/dpp/confirm_delete.html @@ -0,0 +1,38 @@ +{% extends "dpp/base.html" %} + +{% block title %}Delete {{ object }}{% endblock %} + +{% block content %} +
    +
    +

    Confirm Deletion

    +
    + +
    +
    +
    +

    + Are you sure you want to delete {{ object }}? +

    +

    + This action cannot be undone. +

    +
    + +
    + {% csrf_token %} +
    + + + Cancel + +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/mysite/dpp/templates/dpp/create_flow.html b/mysite/dpp/templates/dpp/create_flow.html new file mode 100644 index 0000000..a1d3618 --- /dev/null +++ b/mysite/dpp/templates/dpp/create_flow.html @@ -0,0 +1,24 @@ +{% load i18n %} +{% load static %} + + + + + + + + {% block title %}Create a new product | Lasers4MaaS SCE{% endblock %} + + + +{% block content %} +

    Create a new product

    + +{% csrf_token %} + + + +{% endblock %} diff --git a/mysite/dpp/templates/dpp/dpp_full.html b/mysite/dpp/templates/dpp/dpp_full.html new file mode 100644 index 0000000..d6621da --- /dev/null +++ b/mysite/dpp/templates/dpp/dpp_full.html @@ -0,0 +1,8 @@ +{% extends "dpp/base.html" %} +{% load dpp_extras %} + +{% block title %}DPP Data{% endblock %} +{% block content %} +

    DPP Data

    + {% render_dict dpp_dict %} +{% endblock %} diff --git a/mysite/dpp/templates/dpp/generic_detail.html b/mysite/dpp/templates/dpp/generic_detail.html new file mode 100644 index 0000000..50fd6ab --- /dev/null +++ b/mysite/dpp/templates/dpp/generic_detail.html @@ -0,0 +1,45 @@ +{% extends "dpp/base.html" %} +{% load dpp_extras %} + +{% block title %}{{ object }} Detail{% endblock %} + +{% block content %} +
    +
    +

    {{ object }}

    + +
    + + {% block special_detail %} + {% endblock %} + +
    +
    +

    Details

    +
    +
    +
    + {% for field in object|get_fields %} +
    +
    + {{ field.verbose_name|sentencecase }} +
    +
    + {{ field.value|default:"—" }} +
    +
    + {% endfor %} +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/mysite/dpp/templates/dpp/generic_form.html b/mysite/dpp/templates/dpp/generic_form.html new file mode 100644 index 0000000..0551d44 --- /dev/null +++ b/mysite/dpp/templates/dpp/generic_form.html @@ -0,0 +1,51 @@ +{% extends "dpp/base.html" %} +{% load crispy_forms_tags %} +{% load static %} + +{% block extra_js %} + {{ block.super }} + + + + + + + + + + + {{ form.media }} +{% endblock %} + +{% block title %}{% if object %}Edit{% else %}Create new{% endif %} {{ verbose_name }}{% endblock %} + +{% block content %} +
    +
    +

    + {% if object %}Edit{% else %}Create{% endif %} {{ verbose_name }} +

    +
    + +
    +
    +
    + {% csrf_token %} + {{ form|crispy }} + +

    *: Required field

    +
    + + Cancel + + +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/mysite/dpp/templates/dpp/generic_list.html b/mysite/dpp/templates/dpp/generic_list.html new file mode 100644 index 0000000..314c90a --- /dev/null +++ b/mysite/dpp/templates/dpp/generic_list.html @@ -0,0 +1,99 @@ +{% extends "dpp/base.html" %} +{% load i18n %} +{% load dpp_extras %} + +{% if not is_popup %} +{% block breadcrumbs %} + +{% endblock %} +{% endif %} + +{% block title %}{{ verbose_name|capfirst }} List{% endblock %} + +{% block content %} +
    +
    +

    {{ verbose_name|title }} list

    + + Add {{ verbose_name }} + +
    + +
    + + + + {% for field in opts.fields %} + + {% endfor %} + + + + + {% for object in object_list %} + + {% for field in opts.fields %} + + {% endfor %} + + + {% empty %} + + + + {% endfor %} + +
    + {{ field.verbose_name|upper }} + + Actions +
    + {% with value=object|get_attr:field.name %} + {% if field.get_internal_type == "TextField" %} + {{ value|truncatewords:10 }} + {% else %} + {{ value|default:"—" }} + {% endif %} + {% endwith %} + + View + Edit + Delete +
    + No {{ verbose_name }} records found. +
    +
    + + {% if is_paginated %} +
    + +
    + {% endif %} +
    +{% endblock %} \ No newline at end of file diff --git a/mysite/dpp/templates/dpp/process_detail.html b/mysite/dpp/templates/dpp/process_detail.html new file mode 100644 index 0000000..af57328 --- /dev/null +++ b/mysite/dpp/templates/dpp/process_detail.html @@ -0,0 +1,102 @@ +{% extends "dpp/generic_detail.html" %} +{% load static %} +{% load dpp_extras %} + +{% block special_detail %} + +
    +
    +

    Inputs to process

    + + + {% for input in inputs %} + + + + + + + + {% endfor %} + {% for bioflow in emissions %} + {% if bioflow.direction == 'in' %} + + + + + + + + {% endif%} + {% endfor %} + +
    + {{flow_icons|lookup:input.type}} + {{input.amount}}{{input.product.model.unit}}{{input.product.model.name}} + × +
    ⛏️{{bioflow.amount}}{{bioflow.substance.unit}}{{bioflow.substance.name}} + × +
    + +
    +
    +

    Outputs of process

    + + + + + + + + + + {% for output in outputs %} + + + + + + + + {% endfor %} + {% for bioflow in emissions %} + {% if bioflow.direction == 'out' %} + + + + + + + + {% endif%} + {% endfor %} + +
    Main product{{process.amount}}{{process.functional_flow.model.unit}}{{process.functional_flow.model.name}}View
    + {{flow_icons|lookup:output.type}} + {{output.amount}}{{output.product.model.unit}}{{output.product.model.name}} + × +
    ☁️{{bioflow.amount}}{{bioflow.substance.unit}}{{bioflow.substance.name}} to {{bioflow.compartment}} + × +
    + +
    +
    + +{% endblock %} \ No newline at end of file diff --git a/mysite/dpp/templates/dpp/product_detail.html b/mysite/dpp/templates/dpp/product_detail.html new file mode 100644 index 0000000..70c212b --- /dev/null +++ b/mysite/dpp/templates/dpp/product_detail.html @@ -0,0 +1,145 @@ +{% extends "dpp/generic_detail.html" %} +{% load static %} +{% load dpp_extras %} + +{% block content %} +{{ block.super }} + + +
    +
    +

    DPP details

    +
    +
    + {% for field in object.details|get_fields %} +
    +
    + {{ field.verbose_name|sentencecase }} +
    +
    + {{ field.value|default:"—" }} +
    +
    + + {% empty %} + + {% endfor %} +
    +
    +
    +
    +

    Physical properties

    +
    + {% if object.properties %} + {% with props=object.properties %} + + + + + + + + + + + + + + + + + + + +
    Weight + {{ props.weight|default:"—" }} {{ props.weight_unit }}/{{ object.unit }} +
    Volume + {{ props.volume|default:"—" }} {{ props.volume_unit }}/{{ object.unit }} +
    Density + {{ props.density|default:"—" }} {{ props.weight_unit }}/{{ props.volume_unit }} +
    + The above includes packaging + + {{ props.includes_packaging|yesno:"Yes,No,—" }} +
    + + {% endwith %} + {% else %} + + {% endif %} +
    +
    +
    + + +
    +

    Origin

    + {% if produced_by%} +

    Manufacturer: {{manufacturer}}

    +

    Production process: {{produced_by}}

    + {% else %} +

    Production process: unspecified. Create a new process to describe the production of {{object}}.

    + {% endif %} +
    + + +
    +
    +

    Bill of Materials

    + + + {% for comp in materials %} + + + + + + + {% endfor %} + +
    {{comp.quantity}} {{comp.unit}}{{comp.material.name}} + {% if comp.material.is_hazardous %}⚠️ Hazardous{% elif comp.material.is_critical%}🔴 Critical{% endif %} + + × +
    +
    + Add material +
    + {% csrf_token %} + +
    +
    +
    +
    +

    Componets with missing BoM

    + + + {% for flow in missing_bom %} + + + + + {% empty%} + None + {% endfor %} + +
    Type: {{flow.type}}{{flow.product.model.name}}
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/mysite/dpp/templates/dpp/productionline_detail.html b/mysite/dpp/templates/dpp/productionline_detail.html new file mode 100644 index 0000000..4f78e9d --- /dev/null +++ b/mysite/dpp/templates/dpp/productionline_detail.html @@ -0,0 +1,80 @@ +{% extends "dpp/generic_detail.html" %} + +{% block content %} + +{{ block.super }} + + +
    {{ mermaid_code|safe }}
    + +
    +

    Associated Processes

    + {% if processes %} + + {% else %} +

    No processes associated with this production line.

    + {% endif %} +
    + + + +
    + {% if transport_list %} +

    Transport

    + + {% else %} + + {% endif %} +
    + +
    + {% if publisher %} +

    Publisher status: {{ publisher.show_status }}

    + + Proceed with publishing the DPP + + {% else %} +
    + {% csrf_token %} + + +
    + {% endif %} +
    + + + +{% endblock %} \ No newline at end of file diff --git a/mysite/dpp/templates/dpp/publisher_detail.html b/mysite/dpp/templates/dpp/publisher_detail.html new file mode 100644 index 0000000..4339262 --- /dev/null +++ b/mysite/dpp/templates/dpp/publisher_detail.html @@ -0,0 +1,100 @@ +{% extends "dpp/generic_detail.html" %} +{% load i18n %} +{% load dpp_extras %} + +{% block title %} Publish DPP: {{object.production_line.name}} {% endblock %} + +{% block content %} +
    +

    Publish DPP: {{ object.production_line.name }}

    + + +
    + +
    + +
    +

    Current Status: {{ object.show_status }}

    +

    Last run: {{ object.last_run|date:"Y-m-d H:i" }}

    + {% if object.error_message %} +
    {{ object.error_message }}
    + {% endif %} +
    + +
    + {% for step in steps %} +
    + {% if step.completed %}✅{% elif step.number == object.status|add:1 %}❌{% else %}🔵{% endif %} + {{ step.number }} + {{ step.name }} + + {% if step.can_rerun %} +
    + {% csrf_token %} + + + +
    + {% endif %} +
    + {% endfor %} +
    + +
    +
    + {% csrf_token %} + + +
    + + {% if object.status >= 3 %} +
    + {% csrf_token %} + + +
    + {% endif %} + + {% if object.can_publish %} +
    + {% csrf_token %} + + +
    + {% endif %} +
    +
    + +
    +
    +

    Details

    +
    +
    +
    + {% for field in object|get_fields %} + {% if field.editable %} +
    +
    + {{ field.verbose_name|sentencecase }} +
    +
    + {{ field.value|default:"—" }} +
    +
    + {% endif %} + {% endfor %} +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/mysite/dpp/templates/dpp/welcome.html b/mysite/dpp/templates/dpp/welcome.html new file mode 100644 index 0000000..b1e526e --- /dev/null +++ b/mysite/dpp/templates/dpp/welcome.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} + +{% block title %}Welcome | Lasers4MaaS SCE{% endblock %} + +{% block content %} +

    Welcome to the Lasers4MaaS SCE Platform

    + +{% if user.is_authenticated %} +

    Logged in as {{user.username}}

    +
    + {% csrf_token %} + +
    + +{% if latest_lines %} +

    Recently modified production lines:

    +
    + + + + + + + + + {% for production_line in latest_lines %} + + + + + {% empty %} + + + + {% endfor %} + + + + + +
    + Name + + Last Modified +
    + + {{ production_line.name }} + + + {{ production_line.modified_at|date:"d M Y" }} +
    + No production lines created yet. +
    + + See detailed list + +
    +
    +{% else %} +

    No production lines created yet.

    +{% endif %} + + + +{% else %} +

    You are not logged in

    +Log In +{% endif %} +{% endblock %} diff --git a/mysite/dpp/templates/partials/nested_dict.html b/mysite/dpp/templates/partials/nested_dict.html new file mode 100644 index 0000000..8a62dfa --- /dev/null +++ b/mysite/dpp/templates/partials/nested_dict.html @@ -0,0 +1,17 @@ + +{% load dpp_extras %} +
      + {% for key, value in data.items %} +
    • + {% if value.items %} +
      + {{ key }} + {% render_dict value %} +
      + {% else %} + {{ key }}: + {% if value == '' %}N/A{% else %}{{ value }}{% endif %} + {% endif %} +
    • + {% endfor %} +
    diff --git a/mysite/dpp/templatetags/__init__.py b/mysite/dpp/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mysite/dpp/templatetags/dpp_extras.py b/mysite/dpp/templatetags/dpp_extras.py new file mode 100644 index 0000000..810bbce --- /dev/null +++ b/mysite/dpp/templatetags/dpp_extras.py @@ -0,0 +1,52 @@ +from django import template + +register = template.Library() + +# Used for rendering a dict recursively +@register.inclusion_tag('partials/nested_dict.html') +def render_dict(data): + return {'data': data} + +@register.filter +def get_attr(obj, attr): + """Get attribute from object""" + return getattr(obj, attr, '') + +@register.filter +def get_fields(obj): + """Get all fields from a model instance""" + fields = [] + if not obj: + return [] + for field in obj._meta.fields: + fields.append({ + 'name': field.name, + 'verbose_name': field.verbose_name, + 'value': get_attr(obj, field.name), + 'editable': field.editable, + }) + return fields + +@register.filter +def get_verbose_fields(obj): + """Get the verbose fields (for printing) from a model instance""" + fields = [] + for field in obj._meta.fields: + if field not in ['id']: + name = field.verbose_name if field.verbose_name else field.name + fields.append({ + 'name': name, + 'value': getattr(obj, field.name, '—') + }) + return fields + +@register.filter +def sentencecase(obj: str): + if len(obj) == 1: + return obj.upper() + else: + return obj[0].upper() + obj[1:] + +@register.filter +def lookup(dictionary, key): + return dictionary.get(key) diff --git a/mysite/dpp/tests/__init__.py b/mysite/dpp/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mysite/dpp/tests/test_lca.py b/mysite/dpp/tests/test_lca.py new file mode 100644 index 0000000..ee1489d --- /dev/null +++ b/mysite/dpp/tests/test_lca.py @@ -0,0 +1,457 @@ +""" +Tests for lca.py functions: + setup_project, ensure_methods, select_supply_chain, + convert_dpp_to_brightway, lca_calculations, create_supply_chain_lca + +All Brightway and Django ORM calls are mocked so the suite runs without +a live database or ecoinvent licence. + +Run with: + pytest test_lca.py -v +""" +from django.test import TestCase +import datetime +from unittest.mock import MagicMock, patch +from dpp.lca import ( + EXCLUDED_METHODS, setup_project, ensure_methods, select_supply_chain, + convert_dpp_to_brightway, lca_calculations, create_supply_chain_lca, +) + +# --------------------------------------------------------------------------- +# Helpers to build lightweight mock objects +# --------------------------------------------------------------------------- + +def make_flow(pk=1, name="product", unit="kg", produced_by=None): + flow = MagicMock() + flow.id = pk + flow.pk = pk + flow.__str__ = lambda self: name + flow.model.unit = unit + flow.model.name = name + if produced_by is not None: + flow.manufacturing_info = produced_by + return flow + + +def make_process(pk=1, name="proc", unit="kg", facility=None, + description="desc", amount=1.0, database="mydb"): + proc = MagicMock() + proc.pk = pk + proc.name = name + proc.amount = amount + proc.description = description + proc.database = database + proc.facility = facility + proc.functional_flow.model.unit = unit + proc.__str__ = lambda self: name + return proc + + +# --------------------------------------------------------------------------- +# Test classes for key LCA functions +# --------------------------------------------------------------------------- + +class TestSetupProject(TestCase): + + @patch("dpp.lca.bwi") + @patch("dpp.lca.bwd") + def test_create_new_project(self, mock_bwd, mock_bwi): + """When the project doesn't exist it should be created and configured.""" + mock_bwd.projects.__contains__ = MagicMock(return_value=False) + mock_bwd.projects.current = "L4M-test" + setup_project("L4M-test") + + mock_bwd.projects.set_current.assert_called_once_with("L4M-test") + mock_bwi.remote.install_project.assert_called_once_with( + "ecoinvent-3.10-biosphere", "L4M-test" + ) + + @patch("dpp.lca.bwi") + @patch("dpp.lca.bwd") + def test_reuse_existing_project(self, mock_bwd, mock_bwi): + """When the project already exists only set_current should be called.""" + mock_bwd.projects.__contains__ = MagicMock(return_value=True) + mock_bwd.projects.current = "L4M-test" + setup_project("L4M-test") + + mock_bwd.projects.set_current.assert_called_once_with("L4M-test") + mock_bwi.remote.install_project.assert_not_called() + + +class TestEnsureMethods(TestCase): + + def _bwd_methods(self): + """Return a dict-like object with three EF v3.1 methods.""" + family = "EF v3.1" + keys = [ + (family, "climate change", "GWP100"), + (family, "acidification", "AP"), + ("Other family", "x", "y"), # should be filtered out + ] + methods = {k: {"unit": "kg CO2 eq"} for k in keys} + return methods + + @patch("dpp.lca.bwd") + def test_creates_indicator_set_when_missing(self, mock_bwd): + mock_bwd.methods = self._bwd_methods() + + indicator_set = MagicMock() + indicator_set.name = "EF v3.1" + + with patch("dpp.lca.models.IndicatorSet") as MockIS, \ + patch("dpp.lca.models.ImpactIndicator") as MockII, \ + patch("dpp.lca.models.ImpactCategory") as MockIC: + + MockIS.objects.get.side_effect = MockIS.DoesNotExist + MockIS.DoesNotExist = Exception + MockIS.objects.create.return_value = indicator_set + MockIC.objects.get_or_create.return_value = (MagicMock(), True) + MockII.objects.filter.return_value = [] # no existing indicators + result = ensure_methods("EF v3.1") + + assert result is indicator_set + MockIS.objects.create.assert_called_once_with( + name="EF v3.1", start_date=datetime.date.today() + ) + + @patch("dpp.lca.bwd") + def test_returns_existing_indicator_set(self, mock_bwd): + mock_bwd.methods = self._bwd_methods() + existing_set = MagicMock() + + with patch("dpp.lca.models.IndicatorSet") as MockIS, \ + patch("dpp.lca.models.ImpactIndicator") as MockII, \ + patch("dpp.lca.models.ImpactCategory") as MockIC: + + MockIS.objects.get.return_value = existing_set + # Simulate: existing indicators >= methods, so early return + MockII.objects.filter.return_value = [MagicMock(), MagicMock(), MagicMock()] + result = ensure_methods("EF v3.1") + + assert result is existing_set + + @patch("dpp.lca.bwd") + def test_excludes_known_zero_impact_methods(self, mock_bwd): + """Methods in EXCLUDED_METHODS must never be stored.""" + family = "EF v3.1" + excluded = next(iter(EXCLUDED_METHODS)) # grab one excluded tuple + methods_dict = { + excluded: {"unit": "kg"}, + (family, "acidification", "AP"): {"unit": "kg SO2 eq"}, + } + mock_bwd.methods = methods_dict + + indicator_set = MagicMock() + created_methods = [] + + with patch("dpp.lca.models.IndicatorSet") as MockIS, \ + patch("dpp.lca.models.ImpactIndicator") as MockII, \ + patch("dpp.lca.models.ImpactCategory") as MockIC: + + MockIS.objects.get.side_effect = MockIS.DoesNotExist + MockIS.DoesNotExist = Exception + MockIS.objects.create.return_value = indicator_set + MockIC.objects.get_or_create.return_value = (MagicMock(), True) + MockII.objects.filter.return_value = [] + + def capture_update_or_create(**kwargs): + created_methods.append(kwargs.get("method")) + return MagicMock(), True + + MockII.objects.update_or_create.side_effect = capture_update_or_create + ensure_methods(family) + + # The excluded method's sub-string should not appear + assert excluded[1] not in created_methods + + +class TestSelectSupplyChain(TestCase): + + def test_single_node(self): + """A product with no upstream exchanges returns exactly one process.""" + proc = make_process(pk=1) + proc.prod_exchanges.all.return_value = [] + flow = make_flow(pk=10, produced_by=proc) + + result = select_supply_chain(flow) + assert result == [proc] + + def test_two_level_chain(self): + """Upstream process is discovered through prod_exchanges.""" + upstream_proc = make_process(pk=2) + upstream_proc.prod_exchanges.all.return_value = [] + upstream_flow = make_flow(pk=20, produced_by=upstream_proc) + + root_proc = make_process(pk=1) + exc = MagicMock() + exc.product = upstream_flow + root_proc.prod_exchanges.all.return_value = [exc] + + root_flow = make_flow(pk=10, produced_by=root_proc) + + result = select_supply_chain(root_flow) + assert root_proc in result + assert upstream_proc in result + assert len(result) == 2 + + def test_cycle_is_not_infinite(self): + """Visited-set prevents infinite loops on circular references.""" + proc = make_process(pk=1) + flow = make_flow(pk=10, produced_by=proc) + + # Exchange points back to the same flow (cycle) + exc = MagicMock() + exc.product = flow + proc.prod_exchanges.all.return_value = [exc] + + result = select_supply_chain(flow) # must terminate + assert len(result) == 1 + + def test_max_depth_limits_traversal(self): + """max_depth=1 should stop after root's direct children.""" + deep_proc = make_process(pk=3) + deep_proc.prod_exchanges.all.return_value = [] + deep_flow = make_flow(pk=30, produced_by=deep_proc) + + mid_proc = make_process(pk=2) + mid_exc = MagicMock(); mid_exc.product = deep_flow + mid_proc.prod_exchanges.all.return_value = [mid_exc] + mid_flow = make_flow(pk=20, produced_by=mid_proc) + + root_proc = make_process(pk=1) + root_exc = MagicMock(); root_exc.product = mid_flow + root_proc.prod_exchanges.all.return_value = [root_exc] + root_flow = make_flow(pk=10, produced_by=root_proc) + + # deep_proc is beyond depth=1, should be absent + result = select_supply_chain(root_flow, max_depth=1) + assert deep_proc not in result + + +class TestConvertDppToBrightway(TestCase): + + @patch("dpp.lca.bwd") + def test_output_contains_activity_key(self, mock_bwd): + """Each process must produce a key of the form (db_name, pk).""" + mock_bwd.Database.return_value = iter([]) # empty biosphere + + proc = make_process(pk=42, name="my proc", unit="kg") + + with patch("dpp.lca.models.ProductExchange") as MockPE, \ + patch("dpp.lca.models.EnvExchange") as MockEE: + + MockPE.objects.filter.return_value = [] + MockEE.objects.filter.return_value = [] + + # biosphere db search (used by find_biosphere_flow) not called + mock_bwd.Database.return_value.__iter__ = lambda s: iter([]) + + result = convert_dpp_to_brightway([proc], "testdb") + + assert ("testdb", 42) in result + + @patch("dpp.lca.bwd") + def test_production_exchange_is_present(self, mock_bwd): + """The activity must always have a production exchange.""" + proc = make_process(pk=1, unit="kg", amount=2.0) + + with patch("dpp.lca.models.ProductExchange") as MockPE, \ + patch("dpp.lca.models.EnvExchange") as MockEE: + + MockPE.objects.filter.return_value = [] + MockEE.objects.filter.return_value = [] + + result = convert_dpp_to_brightway([proc], "db") + + activity = result[("db", 1)] + prod_excs = [e for e in activity["exchanges"] if e["type"] == "production"] + assert len(prod_excs) == 1 + assert prod_excs[0]["amount"] == 2.0 + + @patch("dpp.lca.bwd") + def test_stage_raw_material_for_mass_unit(self, mock_bwd): + """Processes with a mass-unit functional flow should be 'Raw material acquisition'.""" + proc = make_process(pk=1, unit="kg") + + with patch("dpp.lca.models.ProductExchange") as MockPE, \ + patch("dpp.lca.models.EnvExchange") as MockEE: + MockPE.objects.filter.return_value = [] + MockEE.objects.filter.return_value = [] + result = convert_dpp_to_brightway([proc], "db") + + assert result[("db", 1)]["stage"] == "Raw material acquisition" + + @patch("dpp.lca.bwd") + def test_stage_manufacturing_for_non_resource_unit(self, mock_bwd): + """Processes with unit 'pcs' (not a resource unit) should be 'Manufacturing'.""" + proc = make_process(pk=1, unit="pcs") + + with patch("dpp.lca.models.ProductExchange") as MockPE, \ + patch("dpp.lca.models.EnvExchange") as MockEE: + MockPE.objects.filter.return_value = [] + MockEE.objects.filter.return_value = [] + result = convert_dpp_to_brightway([proc], "db") + + assert result[("db", 1)]["stage"] == "Manufacturing" + + @patch("dpp.lca.bwd") + def test_cutoff_excludes_external_products(self, mock_bwd): + """ProductExchanges whose upstream process is not in `processes` are cut off.""" + proc = make_process(pk=1, unit="pcs") + external_proc = make_process(pk=99) + + pe = MagicMock() + pe.direction = "in" + pe.amount = 5.0 + pe.product.model.unit = "kg" + pe.product.manufacturing_info = external_proc # not in [proc] + + with patch("dpp.lca.models.ProductExchange") as MockPE, \ + patch("dpp.lca.models.EnvExchange") as MockEE: + MockPE.objects.filter.return_value = [pe] + MockEE.objects.filter.return_value = [] + result = convert_dpp_to_brightway([proc], "db") + + tech_excs = [e for e in result[("db", 1)]["exchanges"] if e["type"] == "technosphere"] + assert len(tech_excs) == 0 + + +class TestLcaCalculations(TestCase): + + @patch("dpp.lca.bwd") + def test_returns_results_list(self, mock_bwd): + """Should return one tuple per non-excluded method.""" + family = "EF v3.1" + methods = [ + (family, "climate change", "GWP100"), + (family, "acidification", "AP"), + ] + mock_bwd.methods = {m: {"unit": "kg CO2 eq"} for m in methods} + mock_bwd.methods.__iter__ = lambda s: iter(methods) + + lca_obj = MagicMock() + lca_obj.score = 3.14 + activity = MagicMock() + activity.lca.return_value = lca_obj + activity.__getitem__ = lambda self, k: "test activity" if k == "name" else None + + results = lca_calculations(activity, family) + assert len(results) == 2 + assert all(len(r) == 3 for r in results) # (method, score, unit) + + @patch("dpp.lca.bwd") + def test_returns_none_when_no_methods(self, mock_bwd, capsys): + """When no matching methods are found the function prints a warning and returns None.""" + mock_bwd.methods = {} + mock_bwd.methods.__iter__ = lambda s: iter([]) + + activity = MagicMock() + activity.__getitem__ = lambda self, k: "x" + + result = lca_calculations(activity, "NonExistentFamily") + assert result is None + captured = capsys.readouterr() + assert "No" in captured.out + + @patch("dpp.lca.bwd") + def test_switch_method_called_for_subsequent_methods(self, mock_bwd): + """dpp.lca.switch_method should be called for every method after the first.""" + family = "EF v3.1" + methods = [ + (family, "a", "x"), + (family, "b", "y"), + (family, "c", "z"), + ] + mock_bwd.methods = {m: {"unit": "u"} for m in methods} + + lca_obj = MagicMock(); lca_obj.score = 1.0 + activity = MagicMock(); activity.lca.return_value = lca_obj + activity.__getitem__ = lambda self, k: "act" + + lca_calculations(activity, family) + assert lca_obj.switch_method.call_count == 2 # once for m[1], once for m[2] + assert lca_obj.lcia.call_count == 2 + + +class TestCreateSupplyChainLca(TestCase): + """Test create_supply_chain_lca. integration-style, heavily mocked""" + + def _setup_bwd(self, mock_bwd, db_name): + mock_db = MagicMock() + mock_bwd.Database.return_value = mock_db + mock_bwd.databases.__contains__ = MagicMock(return_value=False) + + family = "EF v3.1" + methods = [(family, "climate change", "GWP100")] + mock_bwd.methods = {m: {"unit": "kg CO2 eq"} for m in methods} + + ref_activity = MagicMock() + ref_activity.__getitem__ = lambda self, k: "ref act" + lca_obj = MagicMock(); lca_obj.score = 2.0 + ref_activity.lca.return_value = lca_obj + mock_db.get.return_value = ref_activity + + return mock_db + + @patch("dpp.lca.create_supply_chain_lca", wraps=None) # don't wrap; we test the real fn + @patch("dpp.lca.bwd") + @patch("dpp.lca.bwi") + def test_evaluation_created_for_new_product(self, mock_bwi, mock_bwd, _wrap): + proc = make_process(pk=1, unit="kg") + product = make_flow(pk=10, name="widget", produced_by=proc) + + db_name = "dpp_widget_10" + mock_db = self._setup_bwd(mock_bwd, db_name) + mock_bwd.projects.__contains__ = MagicMock(return_value=True) + + with patch("dpp.lca.setup_project"), \ + patch("dpp.lca.ensure_methods") as mock_em, \ + patch("dpp.lca.select_supply_chain", return_value=[proc]), \ + patch("dpp.lca.convert_dpp_to_brightway", return_value={}), \ + patch("dpp.lca.lca_calculations", return_value=[ + (("EF v3.1", "climate change", "GWP100"), 2.0, "kg CO2 eq") + ]), \ + patch("dpp.lca.models.SustainabilityEvaluation") as MockEval, \ + patch("dpp.lca.models.SustainabilityScore") as MockScore, \ + patch("dpp.lca.models.ImpactIndicator") as MockII: + + mock_em.return_value = MagicMock(name="method_set") + MockEval.objects.get_or_create.return_value = (MagicMock(), True) + MockII.objects.get.return_value = MagicMock() + + create_supply_chain_lca(product) + + MockEval.objects.get_or_create.assert_called_once() + MockScore.objects.create.assert_called_once() + + @patch("dpp.lca.bwd") + @patch("dpp.lca.bwi") + def test_scores_updated_when_evaluation_exists(self, mock_bwi, mock_bwd): + proc = make_process(pk=1, unit="kg") + product = make_flow(pk=10, name="widget", produced_by=proc) + + db_name = "dpp_widget_10" + mock_db = self._setup_bwd(mock_bwd, db_name) + mock_bwd.projects.__contains__ = MagicMock(return_value=True) + + with patch("dpp.lca.setup_project"), \ + patch("dpp.lca.ensure_methods") as mock_em, \ + patch("dpp.lca.select_supply_chain", return_value=[proc]), \ + patch("dpp.lca.convert_dpp_to_brightway", return_value={}), \ + patch("dpp.lca.lca_calculations", return_value=[ + (("EF v3.1", "climate change", "GWP100"), 2.0, "kg CO2 eq") + ]), \ + patch("dpp.lca.models.SustainabilityEvaluation") as MockEval, \ + patch("dpp.lca.models.SustainabilityScore") as MockScore, \ + patch("dpp.lca.models.ImpactIndicator") as MockII: + + mock_em.return_value = MagicMock(name="method_set") + # created=False -> evaluation already existed + MockEval.objects.get_or_create.return_value = (MagicMock(), False) + MockII.objects.get.return_value = MagicMock() + + create_supply_chain_lca(product) + + MockScore.objects.filter.return_value.delete.assert_called_once \ + if hasattr(MockScore.objects.filter.return_value, 'delete') else None + MockScore.objects.update_or_create.assert_called_once() diff --git a/mysite/dpp/tests/test_models.py b/mysite/dpp/tests/test_models.py new file mode 100644 index 0000000..de85242 --- /dev/null +++ b/mysite/dpp/tests/test_models.py @@ -0,0 +1,37 @@ +from django.test import TestCase +from django.urls import reverse +from ..models import * + +# Test creating a production line and transport +class ProductionLineTest(TestCase): + def setUp(self): + self.fprod = ProductModel.objects.create( + name="final product", unit="bottles", brand="brand name" + ) + self.operator = Company.objects.create( + name="Example Manufacturer GmbH", + website="www.example.com", + country="DE", + vat_number="DE812345678", + ) + facility = Facility.objects.create( + operator=self.operator, + country='NL', + address="Industrieweg 1, Utrecht", + ) + self.pl = ProductionLine.objects.create( + name="Production line", + description="text", + final_product=self.fprod, + facility=facility, + ) + + def test_create_transport(self): + self.pl.create_transport() + transport = Transport.objects.all() + self.assertIsNotNone(transport) + + def test_production_line_list(self): + response = self.client.get(reverse("dpp:productionline_list")) + response = self.client.get(reverse("dpp:production_line_detail", args=(self.pl.id,))) + diff --git a/mysite/dpp/urls.py b/mysite/dpp/urls.py index 17708aa..964ef1f 100644 --- a/mysite/dpp/urls.py +++ b/mysite/dpp/urls.py @@ -1,11 +1,30 @@ from django.urls import path +from .models import * from . import views app_name = "dpp" urlpatterns = [ - path("home", views.start, name="Welcome Page"), - path("production-lines/create/", views.production_line_create, name="production_line_create"), - path("production-lines//edit/", views.production_line_edit, name="production_line_edit"), -] \ No newline at end of file + path("welcome", views.home, name="home"), + path('productionline//', views.ProductionLineDetailView.as_view(), name='production_line_detail'), + path('process//', views.ProcessDetailView.as_view(), name='process_detail'), + path('product//', views.ProductDetailView.as_view(), name='product_detail'), + path('transports//', views.TransportSubsetView.as_view(), name='transport_subset'), + path('flow/add/', views.FlowCreateView.as_view(), name='flow_add'), + path("publisher//update/", views.PublisherUpdateView.as_view(), name='publisher_update'), + path('publisher//', views.PublisherDetailView.as_view(), name='publisher_detail'), + path('final//', views.DppFullView.as_view(), name='dpp_full'), +] + +# urlpatterns = [] +for model in [Institution, Company, Importer, ServiceOperator, Metadata, Document, Facility, Material, HazardousMaterial, Flow, ProductModel, ProductBatch, SecondaryProduct, DppDetails, ProductProperties, Emission, Composition, ProductItem, Activity, ProductionLine, Process, Exchange, ProductExchange, EnvExchange, LifeCycleEvent, InspectionEvent, MaintenanceEvent, DisassemblyEvent, ItemExchange, ImpactCategory, SustainabilityEvaluation, SustainabilityScore, CircularityEvaluation, CircularityIndicator, CircularityScore, CircularityTracker, Transport]: + name = model.__name__.lower() + pk = "uuid:pk" if isinstance(model._meta.pk, models.UUIDField) else "int:pk" + urlpatterns += [ + path(f"{name}/", getattr(views, f"{model.__name__}List").as_view(), name=f"{name}_list"), + path(f"{name}/add/", getattr(views, f"{model.__name__}Create").as_view(), name=f"{name}_add"), + path(f"{name}/<{pk}>/", getattr(views, f"{model.__name__}Detail").as_view(), name=f"{name}_detail"), + path(f"{name}/<{pk}>/update/", getattr(views, f"{model.__name__}Update").as_view(), name=f"{name}_update"), + path(f"{name}/<{pk}>/delete/", getattr(views, f"{model.__name__}Delete").as_view(), name=f"{name}_delete"), + ] \ No newline at end of file diff --git a/mysite/dpp/views.py b/mysite/dpp/views.py index a8d8c78..09cd0de 100644 --- a/mysite/dpp/views.py +++ b/mysite/dpp/views.py @@ -1,32 +1,442 @@ -from django.shortcuts import render, redirect from django.contrib import messages +from django.forms import ModelChoiceField, ModelMultipleChoiceField from django.http import HttpResponse -from .models import ProductionLine -from api.serializers import ProductionLineSerializer +from django.shortcuts import render, redirect, get_object_or_404 +from django.urls import reverse, reverse_lazy +from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView +from .models import ( + Institution, Company, Importer, ServiceOperator, Metadata, Facility, + Document, Material, HazardousMaterial, + Flow, ProductModel, ProductBatch, ProductItem, SecondaryProduct, + Emission, Composition, DppDetails, ProductProperties, + Activity, ManufacturingProcess, ProductionLine, Process, BackgroundProcess, + Exchange, ProductExchange, EnvExchange, Transport, ItemExchange, + LifeCycleEvent, InspectionEvent, MaintenanceEvent, DisassemblyEvent, + ImpactCategory, SustainabilityEvaluation, SustainabilityScore, + CircularityEvaluation, CircularityIndicator, + CircularityScore, CircularityTracker, Publisher +) +from .forms import get_model_form_plus +from api.serializers import MetadataSerializer -def start(request): - return HttpResponse("Welcome to the Lasers4MaaS project.") +def home(request): + """Welcome page """ + if request.user.is_authenticated: + latest_lines = ProductionLine.objects.filter(created_by=request.user).order_by("-modified_at")[:5] + else: + latest_lines = ProductionLine.objects.order_by("-modified_at")[:5] + context = {'latest_lines': latest_lines} + return render(request, "dpp/welcome.html", context) -def production_line_create(request): - if request.method == "POST": - serializer = ProductionLineSerializer(data=request.POST) - if serializer.is_valid(): - production_line = serializer.save() - messages.success(request, "Production line created successfully!") - return redirect("factory:production_line_edit", pk=production_line.pk) +class AdminTemplateMixin: + """Base class that prepares admin-like context.""" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + model = self.model + opts = model._meta + + context.update({ + "opts": opts, + "app_label": opts.app_label, + "model_name": opts.model_name, + "verbose_name": opts.verbose_name, + "name_plural": opts.verbose_name_plural, + "media": self.get_form().media if hasattr(self, "get_form") else "", + }) + return context + +class PreFillFormMixin: + """ + Mixin that pre-fills form fields from URL query parameters. + Any query parameter matching a field name will be used as initial value. + Also check if it's a popup, and define success_url + """ + def get_initial(self): + initial = super().get_initial() + form_class = self.get_form_class() + for field_name, value in self.request.GET.items(): + if field_name in form_class.base_fields: + field = form_class.base_fields[field_name] + # Convert to PK for relation fields + if isinstance(field, (ModelChoiceField, ModelMultipleChoiceField)): + initial[field_name] = int(value) + else: + initial[field_name] = value + return initial + + def dispatch(self, request, *args, **kwargs): + self.is_popup = request.GET.get('_popup', False) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['is_popup'] = self.is_popup + return context + + def get_success_url(self): + return reverse( + f"{self.model._meta.app_label}:{self.model._meta.model_name}_list" + ) + + def form_valid(self, form): + self.object = form.save() + + # Check if this is a popup request + if self.is_popup: + return HttpResponse( + f''' + + ''' + ) + + return super().form_valid(form) + +def make_crud_views(model): + app_label = model._meta.app_label + + class List(AdminTemplateMixin, ListView): + model = model + paginate_by = 40 + template_name = "dpp/generic_list.html" + # template_name = "adminlike/change_list.html" + + class Detail(AdminTemplateMixin, DetailView): + model = model + template_name = "dpp/generic_detail.html" + # template_name = "adminlike/change_form.html" + + class Create(AdminTemplateMixin, PreFillFormMixin, CreateView): + model = model + fields = "__all__" + template_name = "dpp/generic_form.html" + # template_name = "adminlike/change_form.html" + # form_class = FormWithAutoAdd + + def get_form_class(self): + return get_model_form_plus(self.model, self.fields) + # return forms.modelform_factory( + # self.model, + # fields=self.fields, + # formfield_callback=customize_form + # ) + + # def form_valid(self, form): + # self.object = form.save(commit=False) + # self.object.save() + # form.save_m2m() + # return HttpResponseRedirect(self.get_success_url()) + + # def get_success_url(self): # Return to previous page + # return self.request.META.get('HTTP_REFERER') + + class Update(AdminTemplateMixin, PreFillFormMixin, UpdateView): + model = model + fields = "__all__" + template_name = "dpp/generic_form.html" + # template_name = "adminlike/change_form.html" + # form_class = FormWithAutoAdd + + def get_form_class(self): + return get_model_form_plus(self.model, self.fields) + + class Delete(AdminTemplateMixin, DeleteView): + model = model + success_url = reverse_lazy(f"{app_label}:{model.__name__.lower()}_list") + template_name = "dpp/confirm_delete.html" + + # Assign nice names + List.__name__ = f"{model.__name__}ListView" + Detail.__name__ = f"{model.__name__}DetailView" + Create.__name__ = f"{model.__name__}CreateView" + Update.__name__ = f"{model.__name__}UpdateView" + Delete.__name__ = f"{model.__name__}DeleteView" + + # Wrap in a dictionary + views_dict = { + f"{model.__name__}List": List, + f"{model.__name__}Detail": Detail, + f"{model.__name__}Create": Create, + f"{model.__name__}Update": Update, + f"{model.__name__}Delete": Delete, + } + return views_dict + +# Generate all views automatically +views = {} +for model in [ + Institution, Company, Importer, ServiceOperator, Metadata, Facility, + Document, Material, HazardousMaterial, Emission, + Flow, ProductModel, ProductBatch, SecondaryProduct, Composition, + ProductItem, DppDetails, ProductProperties, + Activity, ProductionLine, ManufacturingProcess, Process, + Exchange, ProductExchange, EnvExchange, Transport, ItemExchange, + LifeCycleEvent, InspectionEvent, MaintenanceEvent, DisassemblyEvent, + ImpactCategory, SustainabilityEvaluation, SustainabilityScore, + CircularityEvaluation, CircularityIndicator, + CircularityScore, CircularityTracker, +]: + views.update(make_crud_views(model)) + +# Make them importable +globals().update(views) + + +def create_flowchart(processes): + """ + Build a Mermaid script for generating a flowchart of processes. + + The output string looks like: + p1{{Electricity}}:::input --> a1 + a2(Steel mill):::outside -->|steel sheet| a1 + a2 -->e1((CO2)):::env + outputs: final outputs, not used in `processes` + suppliers: all production lines supplying input to `processes` + background: all background processes supplying input to `processes` + inputs: all products used by `processes` (or waste going out), but not produced anywhere + """ + outputs = Flow.objects.filter( + produced_by__in=processes + ).exclude(exchanged_by__process__in=processes + ).distinct() + inputs = Flow.objects.filter( + exchanged_by__process__in=processes + ).distinct() + exchanges = ProductExchange.objects.filter(product__produced_by__in=processes).filter(process__in=processes) + suppliers = ManufacturingProcess.objects.filter(functional_flow__in=inputs) + inputs = inputs.exclude(manufacturing_info__in=suppliers).exclude(exchanged_by__in=exchanges) + + # Build Mermaid string + lines = ["flowchart LR"] + lines.append(" classDef process fill:aquamarine,stroke:teal,stroke-width:3px") + lines.append(" classDef product fill:#4CAF50,color:white,stroke:green,stroke-width:3px") + lines.append(" classDef input fill:#2196F3,color:white,stroke:#1565c0,stroke-width:3px") + lines.append(" classDef env fill:#f44336,color:white,stroke:#c62828,stroke-width:3px") + lines.append(" classDef outside fill:#9c27b0,color:white,stroke:#6a1b9a,stroke-width:3px") + lines.append("") + lines.append(' subgraph pl["`**Production line**`"]') + + # Print the core processes + for proc in processes: + lines.append(f" a{proc.id}({proc.name}):::process") + + lines.append(" end") + lines.append(" style pl #ffffde,stroke-width:3px,stroke-dasharray: 5 5") + + # Add unlinked products and emissions + for prod in outputs: + proc = prod.produced_by + lines.append(" a%d --> ff%d{{%s}}:::product" % (proc.id, prod.id, prod)) + + for exch in ProductExchange.objects.filter(product__in=inputs, process__in=processes): + prod, proc = exch.product, exch.process + if exch.direction == 'in': + lines.append(' p%d{{"%s"}}:::input -->a%d' % (prod.id, prod, proc.id)) else: - # HTMX will re-render the form with errors - return render(request, "factory/production_line_form.html", { - "form_data": request.POST, - "errors": serializer.errors, - }) + lines.append(' a%d -->p%d{{"%s"}}:::input' % (proc.id, prod.id, prod)) + for exch in EnvExchange.objects.filter(process__in=processes): + if exch.direction == 'in': + lines.append(' e%d(("%s")):::env -->a%d' % (exch.id, exch.substance.name, exch.process.id)) + else: + lines.append(' a%d --> e%d(("%s")):::env' % (exch.process.id, exch.id, exch.substance.name)) + # Add background processes + for supp in suppliers: + prod = supp.functional_flow + for exch in ProductExchange.objects.filter(product=prod): + if exch.direction == 'in': + lines.append( + f" a{supp.id}({prod}):::outside --> a{exch.process.id}" + # f" a{supp.id}({supp.facility.operator}):::outside -->" + # f"|{prod}| a{exch.process.id}" + ) + else: + lines.append( + f" a{exch.process.id} --> a{supp.id}({prod}):::outside" + ) + # Internal exchanges + for exch in exchanges: + orig = exch.product.produced_by + dest = exch.process + lines.append(" a%d -->|%s| a%d" % (orig.id, exch.product, dest.id)) + + return "\n".join(lines) + +class ProductionLineDetailView(DetailView): + model = ProductionLine + template_name = 'dpp/productionline_detail.html' #'dpp/graph_test.html' # + # context_object_name = 'production_line' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['opts'] = self.model._meta + # Add associated processes to the context + context['processes'] = Process.objects.filter( + production_line=self.object + ).order_by('id') + context['transport_list'] = self.object.transport.all() + # Check for warnings + context['warnings'] = self.object.check_unused_outputs() + # Add Mermaid flowchart + mermaid_code = create_flowchart(context['processes']) + context["mermaid_code"] = mermaid_code + # Find Publisher (if available) + try: + context['publisher'] = self.object.publisher + except Publisher.DoesNotExist: + context['publisher'] = None + return context + + def post(self, request, *args, **kwargs): + if request.POST.get('action') == 'create_publisher': + # Get or create Publisher + pl = self.get_object() + publisher, created = Publisher.objects.get_or_create( + production_line=pl, + amount=100, + issuer=pl.facility.operator, + reo=pl.facility.operator, + credential_format='other', + ) + # Redirect to Publisher detail view + return redirect('dpp:publisher_detail', pk=publisher.pk) + + return super().post(request, *args, **kwargs) + +class ProcessDetailView(AdminTemplateMixin, DetailView): + model = Process + template_name = 'dpp/process_detail.html' + FLOW_ICONS = { + 'prod': '🧩', # Component of the product + 'cons': '🧃', # Consumable + 'ener': '🔥', # Electricity or heat ⚡ + 'util': '⚙️', # Utility or equipment + 'serv': '🧑‍🔧', # Service + 'pack': '📦', # Packaging + 'react': '⚗️', # Reactant 🧪 + 'waste': '🗑️', # Waste + 'texts': ProductExchange.FLOW_TYPES, + } + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['process'] = self.object + # Add associated inputs and outputs to the context + context['inputs'] = ProductExchange.objects.filter( + process=self.object, direction='in' + ) + context['outputs'] = ProductExchange.objects.filter( + process=self.object).exclude(direction='in' + ) + context['emissions'] = EnvExchange.objects.filter( + process=self.object + ) + context['flow_icons'] = self.FLOW_ICONS + return context + +class ProductDetailView(AdminTemplateMixin, DetailView): + model = ProductModel + template_name = 'dpp/product_detail.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['materials'] = self.object.get_composition() + context['manufacturer'] = self.object.manufacturer + context['missing_bom'] = self.object.find_missing_bom() + if hasattr(self.object, 'produced_by'): + context['produced_by'] = self.object.produced_by + else: + context['produced_by'] = None + return context + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + if 'recalculate' in request.POST: + self.object.get_composition(recalculate=True) + return redirect('dpp:product_detail', pk=self.object.pk) + +class TransportSubsetView(AdminTemplateMixin, ListView): + template_name = "dpp/generic_list.html" + model = Transport + + def get_queryset(self): + pl = get_object_or_404(ProductionLine, pk=self.kwargs['productionline']) + queryset = Transport.objects.filter(production_line=pl) + if not queryset: + pl.create_transport() + queryset = Transport.objects.filter(production_line=pl) + return queryset + +class FlowCreateView(CreateView): + model = Flow + template_name = "dpp/create_flow.html" + fields = [] # No user-editable fields - return render(request, "factory/production_line_form.html") +class PublisherUpdateView(AdminTemplateMixin, PreFillFormMixin, UpdateView): + model = Publisher + fields = "__all__" + template_name = "dpp/generic_form.html" +class PublisherDetailView(AdminTemplateMixin, DetailView): + model = Publisher + template_name = "dpp/publisher_detail.html" + + def post(self, request, *args, **kwargs): + publisher = self.get_object() + action = request.POST.get('action') + + if action == 'run_all': + success = publisher.run_from_step(1) + if success: + messages.success(request, "All steps completed successfully!") + else: + messages.error(request, f"Error: {publisher.error_message}") + + elif action == 'rerun_step': + step = int(request.POST.get('step')) + success = publisher.run_from_step(step) + if success: + messages.success(request, f"Steps {step}-5 completed successfully!") + else: + messages.error(request, f"Error: {publisher.error_message}") + + elif action == 'add_subcomponents': + # Special action for step 3 + publisher.production_line.final_product.add_subcomponents() + messages.success(request, "Subcomponents added!") + + elif action == 'publish': + publisher.create_dpps() + messages.success(request, f"{publisher.amount} DPPs created!") + + return redirect('dpp:publisher_detail', pk=publisher.pk) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + publisher = self.get_object() + + # Build step status for template + context['steps'] = [ + { + 'number': i, + 'name': Publisher.STEP_NAMES[i], + 'completed': i <= publisher.status, + 'can_rerun': i <= publisher.status + 1 + } + for i in range(1, 6) + ] + return context -# This view will be used later when we add related objects -def production_line_edit(request, pk): - line = ProductionLine.objects.get(pk=pk) - return render(request, "factory/production_line_edit.html", {"line": line}) +class DppFullView(DetailView): + model = Metadata + template_name = 'dpp/dpp_full.html' + def get_context_data(self, **kwargs): + serialized_data = MetadataSerializer(self.object).data + context = { + 'opts': self.model._meta, + 'dpp_dict': MetadataSerializer(self.object).data, + # 'dpp_dict': self.object.dpp_as_dict(), + } + return context diff --git a/mysite/init_data/circularity_indicators.csv b/mysite/init_data/circularity_indicators.csv new file mode 100644 index 0000000..3c6a0fc --- /dev/null +++ b/mysite/init_data/circularity_indicators.csv @@ -0,0 +1,96 @@ +id,name,is_static,original_attribute,unit,description +R0,Refuse (total),True,,?, +R0.01,Refuse hazardous substances,True,substitutionOfHazardousSubstances,?,% of harzardous substances used relative to the total material requirements and compared to market reference +R0.02,Refuse fossil energy use,True,substitutionOfNonrenewableEnergy,?,% of renewable energy consumption relative to total energy requirements and compared to market reference +R0.03,Refuse non-renewable materials,True,substitutionOfNonrenewableRawMaterials,?,"% of renewable, biobased, or biodegradable materials consumption relative to total material requirements and compared to market reference" +R0.04,Refuse other materials,True,substitutionOfNonvirginMaterials,?,% of virgin material consumption relative to total material requirements and compared to market reference +R0.05,Avoided product consumption,True,avoidedProductConsumption,?,% total number of avoided produced units relative to theoretical maximum production and compared to market reference +R1,Rethink (total),True,,?, +R1.01,Modularity,True,modularity,?,Time (time unit) and number of products needed for disassembly (n) +R1.02,Product take-back,True,productTakeback,?,% of products taken back relative to total sold units +R1.03,Critical Materials,True,criticalMaterials,?,% of critical materials or materials containing CRM relative to the total material requirements +R1.04,Shared use,True,sharedUse,?,Number of expected users by product (n) +R1.05,Durability,True,durability,?,% of time added in lifespan of product or material due to design and business choices +R1.05d,Durability,False,durability,?,Average of the real durability of selected products (time unit) +R1.06,Potential use during lifetime,True,potentialUseDuringLifetime,?,Time of usability (years) +R1.06d,Potential use during lifetime,False,potentialUseDuringLifetime,?,Number of possible usages (n) +R1.07,Multifunctionality,True,multifunctionality,?,Number of possible functions (n) +R1.08,Modularity score,True,modularityScore,?,"Quantitative score reflecting how easily components can be separated and replaced. High modularity improves reuse, repair, and upgrade potential." +R1.09,Materials,True,materials,?,Total number of distinct material types used in the product. Fewer materials typically improve recyclability and reduce environmental impact. +R1.10,Number of components,True,numberOfComponents,?,"Total number of discrete components in the product. Fewer components may indicate a simpler, more resource-efficient design." +R1.11,Material composition complexity,True,materialCompositionComplexity,?,"A calculated score reflecting how complex the material mix is (e.g., multi-material composites are more difficult to recycle)." +R1.12,Number of tools required,True,numberOfToolsRequired,?,Number of different tools required for assembly/disassembly. Fewer tools suggest better maintainability and easier repair. +R1.13,Separable pieces ratio,True,separablePiecesRatio,?,Indicates what proportion of the product can be physically separated without damage. +R2,Reduce (total),True,,?, +R2.01,Raw materials intensity reduction,True,rawMaterialsIntensityReduction,?,% of reduction in materials consumption per production unit relative to compared to market reference +R2.02,Energy intensity reduction,True,energyIntensityReduction,?,% of reduction in energy consumption per production unit relative to compared to market reference +R2.03,Energy consumption reduction,True,energyConsumptionReduction,?,% of reduction in energy consumption relative to compared to market reference +R2.04,Waste generation reduction,True,wasteGenerationReduction,?,% of reductions in waste generation relative to compared to market reference +R2.05,Material losses reduction,True,materialLossesReduction,?,% of reduction in waste generation per production unit relative to compared to market reference +R2.06,Water intensity reduction,True,waterIntensityReduction,?,% of reduction in water consumption per production unit relative to compared to market reference +R2.07,Water consumption,True,waterConsumption,?,% of reduction in water consumption relative to compared to market reference +R3,Reuse (total),True,,?, +R3.01,Reuse rate,True,reuseRate,?,% of reused products relative to total sold units +R3.02,Product take-back,True,productTakeback,?,% of recaptured products relative to total sold units +R3.03,Consumer awareness,True,consumerAwareness,?,% of second life product units sold relative to total produced unit +R3.04,Potential use,True,potentialUse,?,Time of usability (time unit) +R3.04d,Potential use,False,potentialUse,?,Number of possible uses (n) +R3.05,Ownership time,True,ownershipTime,?,Average time of ownership (years) +R3.06,Voidance of reuse barriers,True,voidanceOfReuseBarriers,?,"Indicates whether there are no physical, technical, or legal barriers that prevent reuse (e.g., glued components, license locks)." +R3.07,Reuse potential,True,reusePotential,?,Quantitative score measuring how suitable the product is for reuse without extensive processing. +R3.08,Costs of reuse,True,costsOfReuse,?,"Indicates whether reuse requires costly or energy-intensive refurbishment (e.g., re-coating, testing)." +R3.09,Access to high-value parts,True,accessToHighValueParts,?,"States whether high-value components (e.g., motors, chips) are easily accessible for reuse or resale." +R4,Repair (total),True,,?, +R4.01,Longevity extension,True,longevityExtension,?,Time added in the lifespan of the product or materials (time unit) +R4.02,Extension of producer responsibility,True,extensionOfProducerResponsibility,?,Estimated % of users with access to repair and maintenance services +R4.03,Consumer awareness,True,consumerAwareness,?,% of units engaged in repair model relative to total produced units +R4.04,Potential repair,True,potentialRepair,?,% of products successfully repaired relative to total produced units +R4.05,Repairability score,True,repairabilityScore,?,"Aggregated metric evaluating ease of repair based on disassembly, availability of spare parts, and repair documentation." +R4.06,Durability score,True,durabilityScore,?,"Indicates how resistant the product is to wear and tear over time, based on material quality, stress points, and previous service data." +R4.07,Non-destructive disassembly score,True,nonDestructiveDisassemblyScore,?,"Measures the ability to disassemble components without causing damage (e.g., avoiding cutting, breaking glue seals)." +R4.08,Ease of reassembly,True,easeOfReassebly,?,"Score indicating how easily the product can be reassembled after repair, based on component orientation, fasteners, and clear labeling." +R5,Refurbish (total),True,,?, +R5.01,Product take-back,True,productTakeback,?,% of reclaimed products to refurbishment +R5.02,Refurbished content,True,refurbishedContent,?,% of refurbished parts (or components) in product +R5.03,Refurbishment potential,True,refurbishmentPotential,?,% of units engaged in refurbishing model relative to total produced units +R5.04,Refurbishment score,True,refurbishmentScore,?,"A score representing how easily and effectively a product can be refurbished—e.g., through cleaning, minor repairs, and testing." +R5.05,Upgradability score,True,upgradabilityScore,?,"Measures how easily the product can be upgraded with newer components or firmware, promoting longer life cycles." +R6,Remanufacture (total),True,,?, +R6.01,Product take-back,True,productTakeback,?,% of product reclaimed for remanufacturing relative to total produced units +R6.02,Remanufacturing effectiveness,True,remanufacturingEffectiveness,?,% of remanufactured products relative to total produced units +R6.03,Consumer awareness,True,consumerAwareness,?,% of units engaged in remanufacturing model relative to total produced units +R6.04,Remanufacturing content,True,remanufacturingContent,?,Number of remanufactured parts within the produced unit +R6.05,Remanufacturing score,True,remanufacturingScore,?,"Reflects whether the product is suitable for full remanufacturing: parts tolerance, standardization, testing compatibility." +R7,Repurpose (total),True,,?, +R7.01,Secondary raw materials,True,secondaryrawmaterials,?,% of total of seconday raw materials relative to total material requirements +R7.02,Hazardous waste diverted from disposal,True,hazardouswastedivertedfromdisposal,?,Total weight of hazardous waste diverted from disposal (metric tons) % of repurposing operations +R7.03,Non-hazardous waste diverted from disposal,True,nonhazardouswastedivertedfromdisposal,?,% of nonhazardous waste used for repurpose +R8,Recycle (total),True,,?, +R8.01,Overall recycling rates,True,overallRecyclingRates,?,Expected recycling rate of the materials in the product (%) at EoL +R8.01d,Overall recycling rates,False,overallRecyclingRates,?,Effective recycling rate of the materials in the product (%) at EoL +R8.02,Recycling rate for waste streams,True,recyclingRateForWasteStreams,?,Recycling rate for waste streams (%) at the process level +R8.03,Waste generation,True,wasteGeneration,?,% of residual waste +R8.04,Reverse logistics,True,reverseLogistics,?,Estimated % of customers with access to reverse logistics services +R8.05,Recycling potential,True,recyclingPotential,?,Estimated % of customers reeceiving recycling and composting services +R8.06,Design for recyclability,True,designForRecyclability,?,"A calculated indicator assessing how well the product design supports recycling processes (e.g., avoiding non-detachable parts or hazardous glues)" +R8.07,Recycling compatibility score,True,recyclingCompatibilityScore,?,Assesses how compatible the product is with standard recycling technologies and infrastructure. +R8.08,Material homogeneity score,True,materialHomogeneityScore,?,Measures how uniform the materials are—homogeneous materials are easier and more cost-effective to recycle. +R8.09,Hazardous substance barrier,True,hazardousSubstanceBarrier,?,"Indicates if hazardous materials in the product (e.g., mercury, lead) obstruct the recycling process." +R8.10,High purity sorting possible,True,highPuritySortingPossible,?,"Indicates whether components/materials can be sorted to a high level of purity, enabling better-quality recycled materials." +R8.11,Use of easily recyclable materials,True,useofEasilyRecyclableMaterials,?,"States whether materials like aluminum, PET, or other commonly recyclable materials are used." +R8.12,Recycling collection rate,True,recyclingCollectionRate,?,The expected or observed percentage of products that are collected and processed at end-of-life for recycling. +R9,Recover (total),True,,?, +R9.01,Waste diversion from landfill,True,wasteDiversionFromLandfill,?,% of waste diverted from disposal to energy recovery +R9.02,Potential recovery,True,potentialRecovery,?,Estimated cumulative recovered enegy from process and product EOL through energy recovery (mega joules) +R9.03,Hazardous waste directed to disposal,True,hazardousWasteDirectedToDisposal,?,"Total weight of hazardous waste diverted from disposal (metric tons) +Breakdown of recovery operations (%) " +R9.04,Non-hazardous waste directed to disposal,True,nonhazardousWasteDirectedToDisposal,?,% of nonhazardous waste used for energy recovery +R9.05,Energy recoverability benefit,True,energyRecoverabilityBenefit,?,Total energy consumption of landfill vs waste incineration from process and EoL product +R9.06,Raw materials input,True,rawMaterialsInput,?,% of new materials required for recovery (%) +RE,Circularity enablers (total),True,REnabler,?, +RE.01,Process data access conditions,True,productDataAccessConditions,?,"Describes whether product data (e.g., technical drawings, disassembly instructions) is accessible to third parties." +RE.02,Hardware and software access conditions,True,hardwareSoftwareAccess,?,"Conditions
Specifies whether firmware, diagnostic tools, or software needed for maintenance/repair are accessible (open access, restricted, or proprietary)" +RE.03,Standardised component ratio,True,standardisedComponentRatio,?,"Percentage of components that follow industry standards—facilitates reuse, repair, remanufacture, and interoperability." +RE.04,Component coding used,True,componentCodingUsed,?,"Whether components are labeled with codes (e.g., QR codes, part numbers) to aid in identification and reuse." +RE.05,Material coding used,True,materialCodingUsed,?,"Whether materials are marked according to standards (e.g., plastic resin codes) for improved sorting and recycling." +RE.06,Tracking device used,True,REnablerType,?,"Presence of traceability systems and devices, and machinereadable identifiers for materials and components " diff --git a/mysite/init_data/document_labels.csv b/mysite/init_data/document_labels.csv new file mode 100644 index 0000000..aad345f --- /dev/null +++ b/mysite/init_data/document_labels.csv @@ -0,0 +1,8 @@ +label +Installation / assembly +Use +Repair +Maintenance +Refurbishment +Disassembly +Disposal diff --git a/mysite/init_data/socioecon_indicators.csv b/mysite/init_data/socioecon_indicators.csv new file mode 100644 index 0000000..7a9a8ef --- /dev/null +++ b/mysite/init_data/socioecon_indicators.csv @@ -0,0 +1,11 @@ +method,is_environmental,unit,description +childEmployment,False,Risk.hour,Percentage of children (below legal working age) employed in the product’s supply chain. Risk.hour (%) +forceLabourFrequency,False,Risk.hour,"Measures how frequently forced or bonded labor is detected or suspected across the supply chain. Usually derived from audits or country-specific risk databases. Risk.hour (Cases per 1,000 inhabitants in the country in reference year)" +minimumWage,False,International Dollar,Indicates whether workers receive at least the legally defined minimum wage in the countries of production +nonFatalAccidentsRate,False,/hour,"Rate of non-lethal workplace accidents (e.g., injuries requiring medical attention) per number of employees or working hours. A key occupational health and safety indicator." +fatalAccidentsRate,False,/hour,Rate of workplace fatalities per employee count or working hours. +rightOfAssociation,False,-,Indicates whether employees are free to join trade unions or other worker associations. (Score on an ordinal 0–3 scale: Good / Average / Poor) +genderWageGap,False,%,"Measures the difference in average wages between male and female workers in the supply chain, expressed as a percentage (% difference, reference year)" +economicDevContribution,False,International Dollar,"Captures the economic value generated by the product’s supply chain in local communities. May include employment, taxes paid, infrastructure investments, etc." +valueAdded,False,International Dollar,Potential economic indicators still to be identified +employment,False,-,Potential economic indicators still to be identified diff --git a/mysite/mysite/settings.py b/mysite/mysite/settings.py index 0af5646..fa8799a 100644 --- a/mysite/mysite/settings.py +++ b/mysite/mysite/settings.py @@ -38,7 +38,11 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'django_countries', + 'django_extensions', + 'crispy_forms', + 'crispy_tailwind', 'api', + 'accounts', 'dpp.apps.DppConfig', 'rest_framework', ] @@ -58,7 +62,7 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [BASE_DIR / "templates"], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -72,6 +76,8 @@ WSGI_APPLICATION = 'mysite.wsgi.application' +CRISPY_TEMPLATE_PACK = 'tailwind' + # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases @@ -132,3 +138,7 @@ # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Redirect users to the homepage +LOGIN_REDIRECT_URL = "/dpp/welcome" +LOGOUT_REDIRECT_URL = "/dpp/welcome" diff --git a/mysite/mysite/urls.py b/mysite/mysite/urls.py index 4123a47..06be71e 100644 --- a/mysite/mysite/urls.py +++ b/mysite/mysite/urls.py @@ -20,5 +20,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('api/', include("api.urls")), # API endpoints - path('', include("dpp.urls")), # Frontend application + path('dpp/', include("dpp.urls")), # Frontend application + path("accounts/", include("accounts.urls")), # User registration + path("accounts/", include("django.contrib.auth.urls")), # User login ] diff --git a/mysite/templates/base.html b/mysite/templates/base.html new file mode 100644 index 0000000..01c06a4 --- /dev/null +++ b/mysite/templates/base.html @@ -0,0 +1,36 @@ +{% load i18n %} +{% load static %} + + + + + + + + {% block title %}Lasers4MaaS authentication{% endblock %} + + +{% block header %} + +{% endblock %} + + +
    + {% block content %} + {% endblock %} +
    + + + \ No newline at end of file diff --git a/mysite/templates/registration/login.html b/mysite/templates/registration/login.html new file mode 100644 index 0000000..281777a --- /dev/null +++ b/mysite/templates/registration/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Login{% endblock %} + +{% block content %} +

    Log In

    +
    +
    + {% csrf_token %} + {{ form }} + +
    + Sign Up +
    +{% endblock %} \ No newline at end of file diff --git a/mysite/templates/registration/password_reset_complete.html b/mysite/templates/registration/password_reset_complete.html new file mode 100644 index 0000000..755f8f4 --- /dev/null +++ b/mysite/templates/registration/password_reset_complete.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %} + +{% block title %}Password reset complete{% endblock %} + +{% block content %} +

    Password reset complete

    +

    Your new password has been set. You can log in now on the log in page.

    +{% endblock %} \ No newline at end of file diff --git a/mysite/templates/registration/password_reset_confirm.html b/mysite/templates/registration/password_reset_confirm.html new file mode 100644 index 0000000..86b8cb9 --- /dev/null +++ b/mysite/templates/registration/password_reset_confirm.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}Enter new password{% endblock %} + +{% block content %} + +{% if validlink %} + +

    Set a new password!

    +
    + {% csrf_token %} + {{ form.as_p }} + +
    + +{% else %} + +

    The password reset link was invalid, possibly because it has already been used. Please request a new password reset. +

    + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/mysite/templates/registration/password_reset_done.html b/mysite/templates/registration/password_reset_done.html new file mode 100644 index 0000000..c99e82e --- /dev/null +++ b/mysite/templates/registration/password_reset_done.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block title %}Email Sent{% endblock %} + +{% block content %} +

    Check your inbox.

    +

    We've emailed you instructions for setting your password. You should receive the email shortly!

    +{% endblock %} \ No newline at end of file diff --git a/mysite/templates/registration/password_reset_form.html b/mysite/templates/registration/password_reset_form.html new file mode 100644 index 0000000..bff5fa0 --- /dev/null +++ b/mysite/templates/registration/password_reset_form.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block title %}Forgot Your Password?{% endblock %} + +{% block content %} +

    Forgot your password?

    +

    Enter your email address below, and we'll email instructions for setting a new one.

    + +
    + {% csrf_token %} + {{ form.as_p }} + +
    +{% endblock %} \ No newline at end of file diff --git a/mysite/templates/registration/signup.html b/mysite/templates/registration/signup.html new file mode 100644 index 0000000..d54f68b --- /dev/null +++ b/mysite/templates/registration/signup.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}Create an account{% endblock %} + +{% block content %} +

    Create an account for the Sustainability & Cost Evaluator

    +
    + {% csrf_token %} + {{ form }} + +
    +{% endblock %} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1938b96..5c1e7bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,9 @@ django djangorestframework django-countries +django-extensions +django-crispy-forms +crispy-tailwind environs +pandas +brightway25 diff --git a/static/img/leaf-svgrepo-com.svg b/static/img/leaf-svgrepo-com.svg new file mode 100644 index 0000000..e69de29