diff --git a/lims/README.rst b/lims/README.rst new file mode 100644 index 0000000..825c9db --- /dev/null +++ b/lims/README.rst @@ -0,0 +1,207 @@ +=============================================== +Laboratory Information Management System (LIMS) +=============================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:ffab1ec59149ad1f3ae402cbbc3dcf330af32e4d971e498b3b2fcf487fcb200a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fconnector--lims-lightgray.png?logo=github + :target: https://github.com/OCA/connector-lims/tree/18.0/lims + :alt: OCA/connector-lims +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/connector-lims-18-0/connector-lims-18-0-lims + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/connector-lims&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module is the base of the LIMS application based on LOINC. + +LOINC codes can be updated from here: https://loinc.org/downloads/ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +The base LIMS module can be used with minimal initial configuration. It +also allows for many advanced features, which require a more in-depth +configuration. + +Stages +------ + +The stages are used to monitor progress. Stages can be configured based +on your company's specific business needs. A basic set of stages comes +pre-configured for use. + +1. Go to *LIMS > Configuration > Stages* +2. Create or edit a stage +3. Set the name for the stage. +4. Set the sequence order for the stage. +5. Select *Order* type to apply this stage to your analysis. +6. Additionally, you can set a color for the stage. + +Laboratories +------------ + +You can manage different laboratories. + +Advanced Configurations +----------------------- + +Additional features can be enabled in the General Settings panel for +LIMS. + +1. Go to *LIMS > Configuration > Settings* +2. Enable additional options +3. Configure new options + +Manage Teams +~~~~~~~~~~~~ + +Teams can be used to organize the processing of analysis into groups. +Different teams may have different workflows that an analysis needs to +follow. + +1. Go to *LIMS > Configuration > Operators > Teams* +2. Create or select a team +3. Set the team name, description, and sequence + +You can now define custom stages for each team processing analysis. + +1. Go to *LIMS > Configuration > Stages* +2. Create or edit a stage +3. Select the teams for which this stage should be used + +Manage Categories +~~~~~~~~~~~~~~~~~ + +Categories are used to group operators and the type of analysis an +operator can do. + +1. Go to *LIMS > Configuration > Operators > Categories* +2. Create or select a category +3. Set the name and description of category +4. Additionally, you can select a parent category if required + +Manage Tags +~~~~~~~~~~~ + +Tags can be used to filter and report on analysis + +1. Go to *LIMS > Configuration > Analysis > Tags* +2. Create or select a tag +3. Set the tag name +4. Set a color index for the tag + +Manage Templates +~~~~~~~~~~~~~~~~ + +Templates allow you to create standard templates for your analysis. + +1. Go to *LIMS > Master Data > Templates* +2. Create or select a template +3. Set the name +4. Set the standard order instructions + +Usage +===== + +To use this module, you need to: + +Add LIMS Operators +------------------ + +Operators are the people responsible for performing an analysis. These +operators may be subcontractors or the company's own employees. + +1. Go to *LIMS > Master Data > Operators* +2. Create an operator + +Process Analysis +---------------- + +Once you have established your data, you can begin processing analysis. + +1. Go to *LIMS > Dashboard > Analysis* +2. Create or select an analysis +3. Enter relevant details for the analysis +4. Process the analysis through each stage as defined by your business + requirements + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Open Source Integrators + +Contributors +------------ + +- Rodrigo Madrid Carmona +- Johannan Jasiel Luna García +- Maxime Chambreuil + +Other credits +------------- + +The development of this module has been financially supported by: + +- Ganaderos Asociados de Querétaro + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-max3903| image:: https://github.com/max3903.png?size=40px + :target: https://github.com/max3903 + :alt: max3903 +.. |maintainer-jasiel-osi| image:: https://github.com/jasiel-osi.png?size=40px + :target: https://github.com/jasiel-osi + :alt: jasiel-osi + +Current `maintainers `__: + +|maintainer-max3903| |maintainer-jasiel-osi| + +This module is part of the `OCA/connector-lims `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/lims/__init__.py b/lims/__init__.py new file mode 100644 index 0000000..69f7bab --- /dev/null +++ b/lims/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/lims/__manifest__.py b/lims/__manifest__.py new file mode 100644 index 0000000..f3f039d --- /dev/null +++ b/lims/__manifest__.py @@ -0,0 +1,51 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Laboratory Information Management System (LIMS)", + "summary": "Manage LIMS Instruments, Analysis and Tests", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "category": "LIMS", + "author": "Open Source Integrators, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/connector-lims", + "depends": ["mail", "resource"], + "data": [ + "data/ir_sequence.xml", + "data/mail_message_subtype.xml", + "data/module_category.xml", + "data/lims_stage.xml", + "data/lims_team.xml", + "data/loinc.code.csv", + "data/res_partner.xml", + "data/lims_method.xml", + "data/lims_test.xml", + "security/res_groups.xml", + "security/ir.model.access.csv", + "security/ir_rule.xml", + "views/res_config_settings.xml", + "views/loinc_code.xml", + "views/lims_stage.xml", + "views/lims_tag.xml", + "views/res_partner.xml", + "views/lims_specimen.xml", + "views/lims_order_test.xml", + "views/lims_order.xml", + "views/lims_batch.xml", + "views/lims_category.xml", + "views/lims_instrument.xml", + "views/lims_result.xml", + "views/lims_template.xml", + "views/lims_team.xml", + "views/lims_test.xml", + "views/lims_method.xml", + "views/menu.xml", + ], + "application": True, + "development_status": "Beta", + "maintainers": ["max3903", "jasiel-osi"], + "assets": { + "web.assets_backend": [ + "lims/static/src/scss/team_dashboard.scss", + ] + }, +} diff --git a/lims/data/ir_sequence.xml b/lims/data/ir_sequence.xml new file mode 100644 index 0000000..f791310 --- /dev/null +++ b/lims/data/ir_sequence.xml @@ -0,0 +1,46 @@ + + + + LIMS Analysis + lims.order + LA + 3 + + + + + + LIMS Order Tests + lims.order.test + LT + 3 + + + + + + LIMS Specimen + lims.specimen + LS + 3 + + + + + + LIMS Batch + lims.batch + LB + 3 + + + + + + LIMS Result + lims.result + LR + 3 + + + diff --git a/lims/data/lims_method.xml b/lims/data/lims_method.xml new file mode 100644 index 0000000..2353787 --- /dev/null +++ b/lims/data/lims_method.xml @@ -0,0 +1,9 @@ + + + Non-probe.amp.tar + Influenza A virus 2009 H1N1 RNA [Presence] in Nasopharynx by NAA with non-probe detection + + + diff --git a/lims/data/lims_stage.xml b/lims/data/lims_stage.xml new file mode 100644 index 0000000..944174e --- /dev/null +++ b/lims/data/lims_stage.xml @@ -0,0 +1,162 @@ + + + + New + 10 + True + batch + + + + Pending + 20 + batch + + + + Completed + 90 + batch + True + + + + Cancelled + 100 + True + batch + True + + + + + New + 10 + True + order + + + + Pending + 20 + order + + + + Ready For Planning + 40 + order + + + + Completed + 90 + order + True + + + + Cancelled + 100 + True + order + True + + + + + New + 10 + True + instrument + + + + Active + 20 + instrument + + + + Maintenance + 30 + instrument + True + + + + Scrapped + 100 + True + instrument + True + + + + + New + 10 + True + specimen + + + + Collected + 20 + specimen + + + + Received + 30 + specimen + + + + Processed + 40 + specimen + True + + + + Discarded + 100 + specimen + True + + + + + Undefined + 10 + True + result + + + + Preliminary + 20 + result + + + + Final + 30 + result + + + + Approved + 40 + result + True + + + + Rejected + 100 + result + True + + diff --git a/lims/data/lims_team.xml b/lims/data/lims_team.xml new file mode 100644 index 0000000..2499987 --- /dev/null +++ b/lims/data/lims_team.xml @@ -0,0 +1,5 @@ + + + Default Team + + diff --git a/lims/data/lims_test.xml b/lims/data/lims_test.xml new file mode 100644 index 0000000..e00f783 --- /dev/null +++ b/lims/data/lims_test.xml @@ -0,0 +1,9 @@ + + + Microbiology - parasitic studies + + 10 + 20 + + + diff --git a/lims/data/loinc.code.csv b/lims/data/loinc.code.csv new file mode 100644 index 0000000..a52ede7 --- /dev/null +++ b/lims/data/loinc.code.csv @@ -0,0 +1,23 @@ +loinc_num,component,property,time_aspect,system,scale_type,method_type,loinc_class,loinc_class_type,long_common_name,short_name,external_copyright_notice,status,version_first_released,version_last_changed +18725-2,Microbiology studies,Cmplx,-,^Patient,Set,,ATTACH.LAB,3,Microbiology studies (set),,,ACTIVE,1.0l,2.73 +49489-8,Microbiology section,-,-,^FDA package insert,Nar,,DOC.REF,2,FDA package insert Microbiology section,FDA insert Microbiology,,ACTIVE,2.22,2.34 +59464-8,Microbiologist review,Imp,Pt,XXX,Nar,,MICRO,1,Microbiologist review of results,Microbiologist review,,ACTIVE,2.32,2.73 +88836-2,Microbiology CNAMTS panel,-,Pt,Thrt,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Throat,Micro CNAMTS panel Throat,,ACTIVE,2.64,2.64 +88837-0,Microbiology CNAMTS panel,-,Pt,Sputum,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Sputum,Micro CNAMTS panel Spt,,ACTIVE,2.64,2.64 +88838-8,Microbiology CNAMTS panel,-,Pt,Semen,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Semen,Micro CNAMTS panel Smn,,ACTIVE,2.64,2.64 +88839-6,Microbiology CNAMTS panel,-,Pt,Pus,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Pus,Micro CNAMTS panel Pus,,ACTIVE,2.64,2.64 +88840-4,Microbiology CNAMTS panel,-,Pt,Urethra,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Urethra,Micro CNAMTS panel Urth,,ACTIVE,2.64,2.64 +88841-2,Microbiology CNAMTS panel,-,Pt,Synv fld,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Synovial fluid,Micro CNAMTS panel Snv,,ACTIVE,2.64,2.64 +88842-0,Microbiology CNAMTS panel,-,Pt,CSF,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Cerebral spinal fluid,Micro CNAMTS panel CSF,,ACTIVE,2.64,2.64 +88844-6,Microbiology CNAMTS panel,-,Pt,Bronchial,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Bronchial specimen,Micro CNAMTS panel Bronch,,ACTIVE,2.64,2.64 +88845-3,Microbiology CNAMTS panel,-,Pt,XXX,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Specimen,Micro CNAMTS panel Spec,,ACTIVE,2.64,2.69 +88847-9,Microbiology CNAMTS panel,-,Pt,Stool,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Stool,Micro CNAMTS panel Stl,,ACTIVE,2.64,2.64 +88848-7,Microbiology CNAMTS panel,-,Pt,Urine,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Urine,Micro CNAMTS panel Ur,,ACTIVE,2.64,2.64 +88849-5,Microbiology CNAMTS panel,-,Pt,Vag,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Vaginal fluid,Micro CNAMTS panel Vag,,ACTIVE,2.64,2.64 +88850-3,Microbiology CNAMTS panel,-,Pt,Bld,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Blood,Micro CNAMTS panel Bld,,ACTIVE,2.64,2.64 +88864-4,Microbiology CNAMTS panel,-,Pt,Cornea/Conjunctiva,-,,PANEL.MICRO,1,Microbiology CNAMTS panel - Cornea or Conjunctiva,Micro CNAMTS panel Corn/Cnjt,,ACTIVE,2.64,2.64 +92892-9,Microbiology - parasitic studies,Find,Pt,^Patient,Doc,,DOC.MISC,2,Microbiology - parasitic studies,Parasite studies,,ACTIVE,2.66,2.66 +92893-7,Microbiology - viral studies,Find,Pt,^Patient,Doc,,DOC.MISC,2,Microbiology - viral studies,Viral studies,,ACTIVE,2.66,2.66 +92894-5,Microbiology - bacterial studies,Find,Pt,^Patient,Doc,,DOC.MISC,2,Microbiology - bacterial studies,Bacterial studies,,ACTIVE,2.66,2.66 +96397-5,Microbiology - mycobacteriology studies,Find,Pt,^Patient,Doc,,DOC.MISC,2,Microbiology - mycobacteriology studies Document,Mycobacteriology studies Doc,,ACTIVE,2.69,2.69 +96398-3,Microbiology - mycology studies,Find,Pt,^Patient,Doc,,DOC.MISC,2,Microbiology - mycology studies Document,Mycology studies Doc,,ACTIVE,2.69,2.69 diff --git a/lims/data/mail_message_subtype.xml b/lims/data/mail_message_subtype.xml new file mode 100644 index 0000000..4d129eb --- /dev/null +++ b/lims/data/mail_message_subtype.xml @@ -0,0 +1,59 @@ + + + + Analysis Created + 0 + lims.order + + + Analysis created + + + Analysis Confirmed + 10 + lims.order + + + Analysis confirmed + + + Analysis Scheduled + 20 + lims.order + + + Analysis scheduled + + + Analysis Assigned + 30 + lims.order + + + Analysis assigned + + + Analysis Started + 40 + lims.order + + + Analysis started + + + Analysis Completed + 50 + lims.order + + + Analysis completed + + + Analysis Cancelled + 100 + lims.order + + + Analysis cancelled + + diff --git a/lims/data/module_category.xml b/lims/data/module_category.xml new file mode 100644 index 0000000..1b9f8f2 --- /dev/null +++ b/lims/data/module_category.xml @@ -0,0 +1,6 @@ + + + LIMS + 20 + + diff --git a/lims/data/res_partner.xml b/lims/data/res_partner.xml new file mode 100644 index 0000000..44d8343 --- /dev/null +++ b/lims/data/res_partner.xml @@ -0,0 +1,10 @@ + + + True + + + + Biofire Diagnostics LLC + company + + diff --git a/lims/models/__init__.py b/lims/models/__init__.py new file mode 100644 index 0000000..e35d795 --- /dev/null +++ b/lims/models/__init__.py @@ -0,0 +1,22 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import ( + res_company, + res_config_settings, + loinc_code, + lims_model_mixin, + lims_category, + lims_test, + lims_template, + lims_tag, + lims_stage, + lims_team, + lims_instrument, + lims_specimen, + lims_method, + lims_result, + lims_order_test, + lims_order, + lims_batch, + res_partner, +) diff --git a/lims/models/lims_batch.py b/lims/models/lims_batch.py new file mode 100644 index 0000000..3d1ab7a --- /dev/null +++ b/lims/models/lims_batch.py @@ -0,0 +1,183 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from . import lims_stage + + +class LIMSBatch(models.Model): + _name = "lims.batch" + _description = "LIMS Batch" + _inherit = ["mail.thread", "mail.activity.mixin", "lims.model.mixin"] + + def _default_stage_id(self): + stage = self.env["lims.stage"].search( + [ + ("stage_type", "=", "batch"), + ("is_default", "=", True), + ("company_id", "in", (self.env.company.id, False)), + ], + order="sequence asc", + limit=1, + ) + if stage: + return stage + raise ValidationError(_("You must create a LIMS order stage first.")) + + def _default_team_id(self): + team = self.env["lims.team"].search( + [("company_id", "in", (self.env.company.id, False))], + order="sequence asc", + limit=1, + ) + if team: + return team + raise ValidationError(_("You must create an LIMS team first.")) + + def _default_laboratory_id(self): + rec = self.env["res.partner"].search( + [ + ("is_laboratory", "=", True), + ("company_id", "in", (self.env.company.id, False)), + ], + order="id asc", + limit=1, + ) + if rec: + return rec + raise ValidationError(_("You must create a laboratory first.")) + + stage_id = fields.Many2one( + "lims.stage", + string="Stage", + tracking=True, + index=True, + copy=False, + group_expand="_read_group_stage_ids", + default=lambda self: self._default_stage_id(), + ) + is_closed = fields.Boolean( + "Is closed", + related="stage_id.is_closed", + ) + priority = fields.Selection( + lims_stage.AVAILABLE_PRIORITIES, + index=True, + default=lims_stage.AVAILABLE_PRIORITIES[0][0], + ) + laboratory_id = fields.Many2one( + "res.partner", + string="Laboratory", + default=lambda self: self._default_laboratory_id(), + index=True, + required=True, + tracking=True, + ) + team_id = fields.Many2one( + "lims.team", + string="Team", + default=lambda self: self._default_team_id(), + index=True, + required=True, + tracking=True, + ) + name = fields.Char( + required=True, + index=True, + copy=False, + default=lambda self: _("New"), + ) + test_id = fields.Many2one("lims.test") + test_ids = fields.One2many( + "lims.order.test", + "batch_id", + string="Tests", + domain="[('test_id', '=', test_id.id)]", + ) + company_id = fields.Many2one( + "res.company", + string="Company", + required=True, + index=True, + default=lambda self: self.env.company, + help="Company related to this order", + ) + description = fields.Text() + operator_id = fields.Many2one( + "res.partner", + string="Assigned To", + index=True, + domain="[('is_lims_operator', '=', True)]", + ) + instrument_id = fields.Many2one(related="test_id.instrument_id") + scheduled_date = fields.Date() + + @api.model + def _read_group_stage_ids(self, stages, domain, order=None): + search_domain = [("stage_type", "=", "batch")] + if self.env.context.get("default_team_id"): + search_domain = [ + "&", + ("team_ids", "in", self.env.context["default_team_id"]), + ] + search_domain + return stages.search(search_domain, order=order) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("name", _("New")) == _("New"): + vals["name"] = self.env["ir.sequence"].next_by_code("lims.batch") or _( + "New" + ) + return super().create(vals_list) + + def can_unlink(self): + """:return True if the order can be deleted, False otherwise""" + return self.stage_id == self._default_stage_id() + + def unlink(self): + if all(rec.can_unlink() for rec in self): + return super().unlink() + raise ValidationError(_("You cannot delete this batch.")) + + def action_complete(self): + batch_completed_stage = self.env.ref( + "lims.lims_stage_batch_completed", raise_if_not_found=False + ) + completed_stage = self.env.ref( + "lims.lims_stage_order_completed", raise_if_not_found=False + ) + for batch in self: + # Find tests that are not completed + incomplete_tests = batch.test_ids.filtered( + lambda t: t.stage_id != completed_stage + ) + if incomplete_tests: + raise ValidationError( + _( + "You cannot complete this batch because some associated tests " + "are not yet completed:\n %s" + ) + % ", ".join(incomplete_tests.mapped("name")) + ) + + batch.stage_id = batch_completed_stage.id + return True + + def action_cancel(self): + return self.write( + {"stage_id": self.env.ref("lims.lims_stage_batch_cancelled").id} + ) + + @api.onchange("operator_id", "scheduled_date", "instrument_id") + def _onchange_test_ids(self): + if self.operator_id or self.scheduled_date or self.instrument_id: + self.test_ids.write( + { + "operator_id": self.operator_id.id, + "scheduled_date": self.scheduled_date, + "instrument_id": self.instrument_id.id, + } + ) diff --git a/lims/models/lims_category.py b/lims/models/lims_category.py new file mode 100644 index 0000000..c78499f --- /dev/null +++ b/lims/models/lims_category.py @@ -0,0 +1,32 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class LIMSCategory(models.Model): + _name = "lims.category" + _description = "LIMS Category" + + name = fields.Char(required=True) + parent_id = fields.Many2one("lims.category", string="Parent") + color = fields.Integer("Color Index", default=10) + full_name = fields.Char(compute="_compute_full_name") + description = fields.Char() + company_id = fields.Many2one( + "res.company", + string="Company", + required=False, + index=True, + help="Company related to this category", + ) + + _sql_constraints = [("name_uniq", "unique (name)", "Category name already exists!")] + + def _compute_full_name(self): + for record in self: + record.full_name = ( + record.parent_id.full_name + "/" + record.name + if record.parent_id + else record.name + ) diff --git a/lims/models/lims_instrument.py b/lims/models/lims_instrument.py new file mode 100644 index 0000000..1b698cc --- /dev/null +++ b/lims/models/lims_instrument.py @@ -0,0 +1,59 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models +from odoo.exceptions import ValidationError + + +class LIMSInstrument(models.Model): + _name = "lims.instrument" + _description = "LIMS Instrument" + _inherit = ["mail.thread", "mail.activity.mixin", "lims.model.mixin"] + _stage_type = "instrument" + + def _default_stage_id(self): + stage = self.env["lims.stage"].search( + [ + ("stage_type", "=", "instrument"), + ("is_default", "=", True), + ("company_id", "in", (self.env.company.id, False)), + ], + order="sequence asc", + limit=1, + ) + if stage: + return stage + raise ValidationError(_("You must create a LIMS instrument stage first.")) + + name = fields.Char(required=True) + color = fields.Integer("Color Index", default=0) + operator_id = fields.Many2one( + "res.partner", + string="Assigned Operator", + domain="[('is_lims_operator', '=', True)]", + ) + notes = fields.Text() + laboratory_id = fields.Many2one( + "res.partner", string="Laboratory", domain="[('is_laboratory', '=', True)]" + ) + stage_id = fields.Many2one( + "lims.stage", + string="Stage", + tracking=True, + index=True, + copy=False, + group_expand="_read_group_stage_ids", + default=lambda self: self._default_stage_id(), + ) + company_id = fields.Many2one( + "res.company", + string="Company", + required=True, + index=True, + default=lambda self: self.env.company, + help="Company related to this equipment", + ) + + _sql_constraints = [ + ("name_company_uniq", "unique (name, company_id)", "Instrument already exists!") + ] diff --git a/lims/models/lims_method.py b/lims/models/lims_method.py new file mode 100644 index 0000000..104c795 --- /dev/null +++ b/lims/models/lims_method.py @@ -0,0 +1,18 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class LIMSMethod(models.Model): + _name = "lims.method" + _description = "LIMS Method" + + name = fields.Char(required=True, index=True, copy=False) + description = fields.Text(required=True, copy=False) + partner_id = fields.Many2one( + "res.partner", + string="Manufacturer", + index=True, + copy=False, + ) diff --git a/lims/models/lims_model_mixin.py b/lims/models/lims_model_mixin.py new file mode 100644 index 0000000..81126e9 --- /dev/null +++ b/lims/models/lims_model_mixin.py @@ -0,0 +1,56 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class LIMSModelMixin(models.AbstractModel): + _name = "lims.model.mixin" + _description = "LIMS Model Mixin" + _stage_type = "" + + stage_id = fields.Many2one( + "lims.stage", + string="Stage", + tracking=True, + index=True, + copy=False, + group_expand="_read_group_stage_ids", + default=lambda self: self._default_stage_id(), + ) + hide = fields.Boolean() + + @api.model + def _read_group_stage_ids(self, stages, domain): + return self.env["lims.stage"].search([("stage_type", "=", self._stage_type)]) + + def _default_stage_id(self): + return self.env["lims.stage"].search( + [("stage_type", "=", self._stage_type)], limit=1 + ) + + def new_stage(self, operator): + seq = self.stage_id.sequence + order_by = "asc" if operator == ">" else "desc" + new_stage = self.env["lims.stage"].search( + [("stage_type", "=", self._stage_type), ("sequence", operator, seq)], + order=f"sequence {order_by}", + limit=1, + ) + if new_stage: + self.stage_id = new_stage + self._onchange_stage_id() + + def next_stage(self): + self.new_stage(">") + + def previous_stage(self): + self.new_stage("<") + + @api.onchange("stage_id") + def _onchange_stage_id(self): + # get last stage + highest_stage = self.env["lims.stage"].search( + [("stage_type", "=", self._stage_type)], order="sequence desc", limit=1 + ) + self.hide = self.stage_id.name == highest_stage.name diff --git a/lims/models/lims_order.py b/lims/models/lims_order.py new file mode 100644 index 0000000..c3cfa15 --- /dev/null +++ b/lims/models/lims_order.py @@ -0,0 +1,225 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +from . import lims_stage + + +class LIMSOrder(models.Model): + _name = "lims.order" + _description = "LIMS Order" + _inherit = ["mail.thread", "mail.activity.mixin", "lims.model.mixin"] + _stage_type = "order" + + def _default_stage_id(self): + stage = self.env["lims.stage"].search( + [ + ("stage_type", "=", "order"), + ("is_default", "=", True), + ("company_id", "in", (self.env.company.id, False)), + ], + order="sequence asc", + limit=1, + ) + if stage: + return stage + raise ValidationError(_("You must create a LIMS order stage first.")) + + def _default_team_id(self): + team = self.env["lims.team"].search( + [("company_id", "in", (self.env.company.id, False))], + order="sequence asc", + limit=1, + ) + if team: + return team + raise ValidationError(_("You must create an LIMS team first.")) + + def _default_laboratory_id(self): + rec = self.env["res.partner"].search( + [ + ("is_laboratory", "=", True), + ("company_id", "in", (self.env.company.id, False)), + ], + order="id asc", + limit=1, + ) + if rec: + return rec + raise ValidationError(_("You must create a laboratory first.")) + + def _track_subtype(self, init_values): + self.ensure_one() + if "stage_id" in init_values: + if self.stage_id.id == self.env.ref("lims.lims_stage_order_completed").id: + return self.env.ref("lims.mt_order_completed") + elif self.stage_id.id == self.env.ref("lims.lims_stage_order_cancelled").id: + return self.env.ref("lims.mt_order_cancelled") + return super()._track_subtype(init_values) + + stage_id = fields.Many2one( + "lims.stage", + string="Stage", + tracking=True, + index=True, + copy=False, + group_expand="_read_group_stage_ids", + default=lambda self: self._default_stage_id(), + ) + is_closed = fields.Boolean( + "Is closed", + related="stage_id.is_closed", + ) + priority = fields.Selection( + lims_stage.AVAILABLE_PRIORITIES, + index=True, + default=lims_stage.AVAILABLE_PRIORITIES[0][0], + ) + tag_ids = fields.Many2many( + "lims.tag", + "lims_order_tag_rel", + "lims_order_id", + "tag_id", + string="Tags", + help="Classify and analyze your analysis", + ) + color = fields.Integer("Color Index", default=0) + laboratory_id = fields.Many2one( + "res.partner", + string="Laboratory", + default=lambda self: self._default_laboratory_id(), + index=True, + required=True, + tracking=True, + domain="[('is_laboratory', '=', True)]", + ) + team_id = fields.Many2one( + "lims.team", + string="Team", + default=lambda self: self._default_team_id(), + index=True, + required=True, + tracking=True, + ) + + # Request + name = fields.Char( + required=True, + index=True, + copy=False, + default=lambda self: _("New"), + ) + test_ids = fields.One2many("lims.order.test", "order_id", string="Tests") + partner_id = fields.Many2one( + "res.partner", + string="Partner", + tracking=True, + index=True, + copy=False, + ) + company_id = fields.Many2one( + "res.company", + string="Company", + required=True, + index=True, + default=lambda self: self.env.company, + help="Company related to this order", + ) + description = fields.Text() + operator_id = fields.Many2one( + "res.partner", + string="Assigned To", + index=True, + domain="[('is_lims_operator', '=', True)]", + ) + physician_id = fields.Many2one( + "res.partner", + string="Ordering Physician", + index=True, + domain="[('is_physician', '=', True)]", + ) + specimen_id = fields.Many2one("lims.specimen", string="Specimen", index=True) + scheduled_date = fields.Date() + date = fields.Date() + template_id = fields.Many2one("lims.template", string="Template") + category_ids = fields.Many2many("lims.category", string="Categories") + instrument_id = fields.Many2one("lims.instrument", string="Instrument") + + @api.model + def _read_group_stage_ids(self, stages, domain, order=None): + search_domain = [("stage_type", "=", "order")] + if self.env.context.get("default_team_id"): + search_domain = [ + "&", + ("team_ids", "in", self.env.context["default_team_id"]), + ] + search_domain + return stages.search(search_domain, order=order) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("name", _("New")) == _("New"): + vals["name"] = self.env["ir.sequence"].next_by_code("lims.order") or _( + "New" + ) + return super().create(vals_list) + + def write(self, vals): + context = dict(self.env.context or {}) + completed_stage = self.env.ref( + "lims.lims_stage_order_completed", raise_if_not_found=False + ) + if ( + context.get("default_stage_id") + and vals.get("stage_id") == completed_stage.id + ): + raise UserError( + _( + "You cannot move an order to the Completed stage" + " directly from Kanban view." + ) + ) + return super().write(vals) + + def can_unlink(self): + """:return True if the order can be deleted, False otherwise""" + return self.stage_id == self._default_stage_id() + + def unlink(self): + if all(order.can_unlink() for order in self): + return super().unlink() + raise ValidationError(_("You cannot delete this order.")) + + def action_complete(self): + return self.write( + { + "stage_id": self.env.ref("lims.lims_stage_order_completed").id, + } + ) + + def action_cancel(self): + return self.write( + {"stage_id": self.env.ref("lims.lims_stage_order_cancelled").id} + ) + + def _prepare_order_test_values(self): + order_test_data = [fields.Command.clear()] + for test in self.template_id.test_ids: + values = test._prepare_order_test_values() + values.update({"operator_id": self.template_id.operator_id.id}) + order_test_data += [fields.Command.create(values)] + return order_test_data + + @api.onchange("template_id") + def _onchange_template_id(self): + if self.template_id: + self.write( + { + "operator_id": self.template_id.operator_id.id, + "category_ids": self.template_id.category_ids, + "test_ids": self._prepare_order_test_values(), + } + ) diff --git a/lims/models/lims_order_test.py b/lims/models/lims_order_test.py new file mode 100644 index 0000000..b080f84 --- /dev/null +++ b/lims/models/lims_order_test.py @@ -0,0 +1,153 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class LIMSOrderTest(models.Model): + _name = "lims.order.test" + _description = "LIMS Order Test" + _inherit = ["mail.thread", "mail.activity.mixin", "lims.model.mixin"] + _stage_type = "order" + + def _default_stage_id(self): + stage = self.env["lims.stage"].search( + [ + ("stage_type", "=", "order"), + ("is_default", "=", True), + ("company_id", "in", (self.env.company.id, False)), + ], + order="sequence asc", + limit=1, + ) + if stage: + return stage + raise ValidationError(_("You must create a LIMS order stage first.")) + + def _track_subtype(self, init_values): + self.ensure_one() + if "stage_id" in init_values: + if self.stage_id.id == self.env.ref("lims.lims_stage_order_completed").id: + return self.env.ref("lims.mt_order_completed") + elif self.stage_id.id == self.env.ref("lims.lims_stage_order_cancelled").id: + return self.env.ref("lims.mt_order_cancelled") + return super()._track_subtype(init_values) + + stage_id = fields.Many2one( + "lims.stage", + string="Stage", + tracking=True, + index=True, + copy=False, + group_expand="_read_group_stage_ids", + default=lambda self: self._default_stage_id(), + ) + is_closed = fields.Boolean( + "Is closed", + related="stage_id.is_closed", + ) + category_ids = fields.Many2many("lims.category", string="Categories") + tag_ids = fields.Many2many( + "lims.tag", + "lims_order_line_tag_rel", + "lims_order_line_id", + "tag_id", + string="Tags", + help="Classify and analyze your work orders", + ) + batch_id = fields.Many2one( + "lims.batch", + string="Batch", + index=True, + ) + laboratory_id = fields.Many2one( + "res.partner", + string="Laboratory", + related="order_id.laboratory_id", + index=True, + required=True, + tracking=True, + ) + team_id = fields.Many2one( + "lims.team", + string="Team", + related="order_id.team_id", + index=True, + required=True, + tracking=True, + ) + name = fields.Char( + required=True, + index=True, + copy=False, + default=lambda self: _("New"), + ) + order_id = fields.Many2one( + "lims.order", + string="Analysis", + required=True, + index=True, + ) + company_id = fields.Many2one( + "res.company", + string="Company", + required=True, + index=True, + default=lambda self: self.env.company, + help="Company related to this order", + ) + specimen_id = fields.Many2one( + "lims.specimen", string="Specimen", related="order_id.specimen_id", index=True + ) + operator_id = fields.Many2one( + "res.partner", + string="Assigned To", + index=True, + domain="[('is_lims_operator', '=', True)]", + ) + scheduled_date = fields.Datetime() + date = fields.Datetime() + todo = fields.Text(string="Instructions", related="test_id.method_id.description") + instrument_id = fields.Many2one("lims.instrument") + test_id = fields.Many2one("lims.test") + result_ids = fields.One2many("lims.result", "order_test_id", string="Results") + + @api.model + def _read_group_stage_ids(self, stages, domain, order=None): + search_domain = [("stage_type", "=", "order")] + if self.env.context.get("default_team_id"): + search_domain = [ + "&", + ("team_ids", "in", self.env.context["default_team_id"]), + ] + search_domain + return stages.search(search_domain, order=order) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("name", _("New")) == _("New"): + vals["name"] = self.env["ir.sequence"].next_by_code( + "lims.order.test" + ) or _("New") + return super().create(vals_list) + + def can_unlink(self): + """:return True if the order can be deleted, False otherwise""" + return self.stage_id == self._default_stage_id() + + def unlink(self): + if all(order.can_unlink() for order in self): + return super().unlink() + raise ValidationError(_("You cannot delete this order.")) + + def action_complete(self): + return self.write( + {"stage_id": self.env.ref("lims.lims_stage_order_completed").id} + ) + + def action_cancel(self): + return self.write( + {"stage_id": self.env.ref("lims.lims_stage_order_cancelled").id} + ) diff --git a/lims/models/lims_result.py b/lims/models/lims_result.py new file mode 100644 index 0000000..93f8920 --- /dev/null +++ b/lims/models/lims_result.py @@ -0,0 +1,69 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class LIMSResult(models.Model): + _name = "lims.result" + _inherit = ["mail.thread", "mail.activity.mixin", "lims.model.mixin"] + _description = "LIMS Result" + _stage_type = "result" + + def _default_stage_id(self): + stage = self.env["lims.stage"].search( + [ + ("stage_type", "=", "result"), + ("is_default", "=", True), + ("company_id", "in", (self.env.company.id, False)), + ], + order="sequence asc", + limit=1, + ) + if stage: + return stage + raise ValidationError(_("You must create a LIMS result stage first.")) + + name = fields.Char( + required=True, + index=True, + copy=False, + default=lambda self: _("New"), + ) + description = fields.Char() + result_value = fields.Float() + result_date = fields.Date() + result_min = fields.Float(related="order_test_id.test_id.normal_range_min") + result_max = fields.Float(related="order_test_id.test_id.normal_range_max") + interpretation = fields.Selection( + [("preliminary", "Preliminary"), ("final", "Final"), ("approved", "Approved")] + ) + stage_id = fields.Many2one( + "lims.stage", + string="Stage", + tracking=True, + index=True, + copy=False, + group_expand="_read_group_stage_ids", + default=lambda self: self._default_stage_id(), + ) + partner_id = fields.Many2one( + related="order_test_id.specimen_id.partner_id", + tracking=True, + index=True, + copy=False, + ) + validated_by_user_id = fields.Many2one("res.users") + order_test_id = fields.Many2one("lims.order.test") + team_id = fields.Many2one(related="order_test_id.team_id") + specimen_id = fields.Many2one(related="order_test_id.specimen_id") + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("name", _("New")) == _("New"): + vals["name"] = self.env["ir.sequence"].next_by_code("lims.result") or _( + "New" + ) + return super().create(vals_list) diff --git a/lims/models/lims_specimen.py b/lims/models/lims_specimen.py new file mode 100644 index 0000000..f0528cf --- /dev/null +++ b/lims/models/lims_specimen.py @@ -0,0 +1,61 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class LIMSSpecimen(models.Model): + _name = "lims.specimen" + _inherit = ["mail.thread", "mail.activity.mixin", "lims.model.mixin"] + _description = "LIMS Specimen" + _stage_type = "specimen" + + def _default_stage_id(self): + stage = self.env["lims.stage"].search( + [ + ("stage_type", "=", "specimen"), + ("is_default", "=", True), + ("company_id", "in", (self.env.company.id, False)), + ], + order="sequence asc", + limit=1, + ) + if stage: + return stage + raise ValidationError(_("You must create a LIMS specimen stage first.")) + + name = fields.Char( + required=True, + index=True, + copy=False, + default=lambda self: _("New"), + ) + stage_id = fields.Many2one( + "lims.stage", + string="Stage", + tracking=True, + index=True, + copy=False, + group_expand="_read_group_stage_ids", + default=lambda self: self._default_stage_id(), + ) + partner_id = fields.Many2one( + "res.partner", + string="Owner", + tracking=True, + index=True, + copy=False, + ) + collection_date = fields.Datetime() + specimen_type = fields.Char() + order_id = fields.Many2one("lims.order") + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("name", _("New")) == _("New"): + vals["name"] = self.env["ir.sequence"].next_by_code( + "lims.specimen" + ) or _("New") + return super().create(vals_list) diff --git a/lims/models/lims_stage.py b/lims/models/lims_stage.py new file mode 100644 index 0000000..c96a1a5 --- /dev/null +++ b/lims/models/lims_stage.py @@ -0,0 +1,81 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +AVAILABLE_PRIORITIES = [("0", "Normal"), ("1", "Low"), ("2", "High"), ("3", "Urgent")] + + +class FSMStage(models.Model): + _name = "lims.stage" + _description = "LIMS Stage" + _order = "sequence, name, id" + + def _default_team_ids(self): + default_team_id = self.env.context.get("default_team_id") + return [default_team_id] if default_team_id else None + + active = fields.Boolean(default=True) + name = fields.Char(required=True) + sequence = fields.Integer(default=1, help="Used to order stages. Lower is better.") + legend_priority = fields.Text( + "Priority Management Explanation", + translate=True, + help="Explanation text to help users using" + " the star and priority mechanism on" + " stages or orders that are in this" + " stage.", + ) + fold = fields.Boolean( + "Folded in Kanban", + help="This stage is folded in the kanban view when " + "there are no record in that stage to display.", + ) + is_closed = fields.Boolean( + "Is a close stage", help="Services in this stage are considered as closed." + ) + is_default = fields.Boolean("Is a default stage", help="Used a default stage") + description = fields.Text(translate=True) + stage_type = fields.Selection( + [ + ("order", "Order"), + ("batch", "Batch"), + ("specimen", "Specimen"), + ("instrument", "Instrument"), + ("result", "Result"), + ], + "Apply on", + required=True, + default="order", + ) + company_id = fields.Many2one( + "res.company", + string="Company", + default=lambda self: self.env.user.company_id.id, + ) + team_ids = fields.Many2many( + "lims.team", + "order_team_stage_rel", + "stage_id", + "team_id", + string="Teams", + default=lambda self: self._default_team_ids(), + ) + + @api.model_create_multi + def create(self, vals_list): + stages = self.search([]) + for vals in vals_list: + for stage in stages: + if stage.stage_type == vals.get( + "stage_type" + ) and stage.sequence == vals.get("sequence"): + raise ValidationError( + _( + "Cannot create LIMS Stage because " + "it has the same Type and Sequence " + "of an existing one." + ) + ) + return super().create(vals_list) diff --git a/lims/models/lims_tag.py b/lims/models/lims_tag.py new file mode 100644 index 0000000..a0d2c9d --- /dev/null +++ b/lims/models/lims_tag.py @@ -0,0 +1,32 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class LIMSTag(models.Model): + _name = "lims.tag" + _description = "LIMS Tag" + + name = fields.Char(required=True) + parent_id = fields.Many2one("lims.tag", string="Parent") + color = fields.Integer("Color Index", default=10) + full_name = fields.Char(compute="_compute_full_name") + company_id = fields.Many2one( + "res.company", + string="Company", + required=True, + index=True, + default=lambda self: self.env.user.company_id, + help="Company related to this tag", + ) + + _sql_constraints = [("name_uniq", "unique (name)", "Tag name already exists!")] + + def _compute_full_name(self): + for record in self: + record.full_name = ( + record.parent_id.name + "/" + record.name + if record.parent_id + else record.name + ) diff --git a/lims/models/lims_team.py b/lims/models/lims_team.py new file mode 100644 index 0000000..ad2444f --- /dev/null +++ b/lims/models/lims_team.py @@ -0,0 +1,91 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class LIMSTeam(models.Model): + _name = "lims.team" + _description = "LIMS Team" + _inherit = ["mail.thread", "mail.activity.mixin"] + + def _default_stages(self): + return self.env["lims.stage"].search([("is_default", "=", True)]) + + def _compute_order_count(self): + order_data = self.env["lims.order"].read_group( + [("team_id", "in", self.ids), ("stage_id.is_closed", "=", False)], + ["team_id"], + ["team_id"], + ) + result = {data["team_id"][0]: int(data["team_id_count"]) for data in order_data} + for team in self: + team.order_count = result.get(team.id, 0) + + def _compute_order_need_assign_count(self): + order_data = self.env["lims.order"].read_group( + [ + ("team_id", "in", self.ids), + ("operator_id", "=", False), + ("stage_id.is_closed", "=", False), + ], + ["team_id"], + ["team_id"], + ) + result = {data["team_id"][0]: int(data["team_id_count"]) for data in order_data} + for team in self: + team.order_need_assign_count = result.get(team.id, 0) + + def _compute_order_need_schedule_count(self): + order_data = self.env["lims.order"].read_group( + [ + ("team_id", "in", self.ids), + ("scheduled_date", "=", False), + ("stage_id.is_closed", "=", False), + ], + ["team_id"], + ["team_id"], + ) + result = {data["team_id"][0]: int(data["team_id_count"]) for data in order_data} + for team in self: + team.order_need_schedule_count = result.get(team.id, 0) + + name = fields.Char(required=True, translate=True) + description = fields.Text(translate=True) + color = fields.Integer("Color Index") + stage_ids = fields.Many2many( + "lims.stage", + "lims_order_team_stage_rel", + "team_id", + "stage_id", + string="Stages", + default=_default_stages, + ) + order_ids = fields.One2many( + "lims.order", + "team_id", + string="Analysis", + domain=[("stage_id.is_closed", "=", False)], + ) + order_count = fields.Integer( + compute="_compute_order_count", string="Analysis Count" + ) + order_need_assign_count = fields.Integer( + compute="_compute_order_need_assign_count", string="Analysis to Assign" + ) + order_need_schedule_count = fields.Integer( + compute="_compute_order_need_schedule_count", string="Analysis to Schedule" + ) + sequence = fields.Integer(default=1, help="Used to sort teams. Lower is better.") + company_id = fields.Many2one( + "res.company", + string="Company", + required=True, + index=True, + default=lambda self: self.env.company, + help="Company related to this team", + ) + + _sql_constraints = [ + ("name_uniq", "unique (name, company_id)", "Team name already exists!") + ] diff --git a/lims/models/lims_template.py b/lims/models/lims_template.py new file mode 100644 index 0000000..365d83b --- /dev/null +++ b/lims/models/lims_template.py @@ -0,0 +1,26 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class LIMSTemplate(models.Model): + _name = "lims.template" + _description = "LIMS Order Template" + + name = fields.Char(required=True) + operator_id = fields.Many2one( + "res.partner", string="Operator", domain="[('is_lims_operator', '=', True)]" + ) + physician_id = fields.Many2one( + "res.partner", domain="[('is_physician', '=', True)]" + ) + test_ids = fields.Many2many("lims.test", string="Tests") + category_ids = fields.Many2many("lims.category", string="Categories") + tag_ids = fields.Many2many("lims.tag", string="Tags") + company_id = fields.Many2one( + "res.company", + string="Company", + index=True, + help="Company related to this template", + ) diff --git a/lims/models/lims_test.py b/lims/models/lims_test.py new file mode 100644 index 0000000..71bafa6 --- /dev/null +++ b/lims/models/lims_test.py @@ -0,0 +1,37 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class LIMSTest(models.Model): + _name = "lims.test" + _description = "LIMS Test" + + name = fields.Char(required=True) + loinc_code_id = fields.Many2one("loinc.code") + description = fields.Text() + normal_range_min = fields.Float() + normal_range_max = fields.Float() + method_id = fields.Many2one("lims.method") + instrument_id = fields.Many2one("lims.instrument") + template_ids = fields.Many2many("lims.template") + company_id = fields.Many2one( + "res.company", + string="Company", + index=True, + help="Company related to this test", + ) + + def _prepare_order_test_values(self): + """Give the values to create the corresponding order test. + + :return: `lims.order.test` create values + :rtype: dict + """ + self.ensure_one() + return { + "name": "New", + "test_id": self.id, + "instrument_id": self.instrument_id.id, + } diff --git a/lims/models/loinc_code.py b/lims/models/loinc_code.py new file mode 100644 index 0000000..684eefb --- /dev/null +++ b/lims/models/loinc_code.py @@ -0,0 +1,40 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class LOINCCode(models.Model): + _name = "loinc.code" + _description = "LOINC Codes" + _rec_name = "loinc_num" + + loinc_num = fields.Char(required=True, index=True, copy=False, help="1975-2") + long_common_name = fields.Char( + copy=False, help="Glucose [Mass/volume] in Serum or Plasma" + ) + short_name = fields.Char(copy=False, help="Glucose SerPl-mCnc") + component = fields.Char(required=True, index=True, copy=False, help="Glucose") + property = fields.Char(required=True, index=True, copy=False, help="Mass/volume") + time_aspect = fields.Char( + required=True, index=True, copy=False, help="Pt for Point in time" + ) + system = fields.Char( + required=True, index=True, copy=False, help="Ser/Plas for Serum or Plasma" + ) + scale_type = fields.Char( + required=True, index=True, copy=False, help="Qn for Quantitative" + ) + method_type = fields.Char(copy=False, help="Colorimetric") + unit_code = fields.Char(help="mg/dL") + loinc_class = fields.Char(string="Class", required=True, copy=False, help="CHEM") + loinc_class_type = fields.Integer(string="Class Type", required=True, copy=False) + external_copyright_notice = fields.Char(copy=False) + version_first_released = fields.Char(required=True, copy=False) + version_last_changed = fields.Char(required=True, copy=False) + status = fields.Char( + required=True, + index=True, + copy=False, + help="ACTIVE, DEPRECATED, DISCOURAGED, TRIAL", + ) diff --git a/lims/models/res_company.py b/lims/models/res_company.py new file mode 100644 index 0000000..9889493 --- /dev/null +++ b/lims/models/res_company.py @@ -0,0 +1,24 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + lims_order_request_late_lowest = fields.Float( + string="Hours of Buffer for Lowest Priority LIMS Orders", + default=72, + ) + lims_order_request_late_low = fields.Float( + string="Hours of Buffer for Low Priority LIMS Orders", + default=48, + ) + lims_order_request_late_medium = fields.Float( + string="Hours of Buffer for Medium Priority LIMS Orders", + default=24, + ) + lims_order_request_late_high = fields.Float( + string="Hours of Buffer for High Priority LIMS Orders", default=8 + ) diff --git a/lims/models/res_config_settings.py b/lims/models/res_config_settings.py new file mode 100644 index 0000000..41c6e7d --- /dev/null +++ b/lims/models/res_config_settings.py @@ -0,0 +1,65 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + # Groups + group_lims_laboratory = fields.Boolean( + string="Manage Laboratories", implied_group="lims.group_lims_laboratory" + ) + group_lims_team = fields.Boolean( + string="Manage Teams", implied_group="lims.group_lims_team" + ) + group_lims_category = fields.Boolean( + string="Manage Categories", implied_group="lims.group_lims_category" + ) + group_lims_tag = fields.Boolean( + string="Manage Tags", implied_group="lims.group_lims_tag" + ) + group_lims_instrument = fields.Boolean( + string="Manage Instruments", implied_group="lims.group_lims_instrument" + ) + group_lims_template = fields.Boolean( + string="Manage Templates", implied_group="lims.group_lims_template" + ) + group_lims_batch = fields.Boolean( + string="Manage Batches", implied_group="lims.group_lims_batch" + ) + + # Modules + module_lims_account = fields.Boolean(string="Invoice your analysis") + module_lims_maintenance = fields.Boolean( + string="Manage maintenance of your equipments" + ) + module_lims_purchase = fields.Boolean( + string="Manage subcontractors and their pricelists" + ) + module_lims_sale = fields.Boolean(string="Sell LIMS services") + module_lims_stock = fields.Boolean(string="Use Odoo Logistics") + module_lims_hl7 = fields.Boolean(string="Exchange data using HL7") + + # Priorities + lims_order_request_late_lowest = fields.Float( + string="Hours of Buffer for Lowest Priority LIMS Orders", + related="company_id.lims_order_request_late_lowest", + readonly=False, + ) + lims_order_request_late_low = fields.Float( + string="Hours of Buffer for Low Priority LIMS Orders", + related="company_id.lims_order_request_late_low", + readonly=False, + ) + lims_order_request_late_medium = fields.Float( + string="Hours of Buffer for Medium Priority LIMS Orders", + related="company_id.lims_order_request_late_medium", + readonly=False, + ) + lims_order_request_late_high = fields.Float( + string="Hours of Buffer for High Priority LIMS Orders", + related="company_id.lims_order_request_late_high", + readonly=False, + ) diff --git a/lims/models/res_partner.py b/lims/models/res_partner.py new file mode 100644 index 0000000..40af564 --- /dev/null +++ b/lims/models/res_partner.py @@ -0,0 +1,12 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + is_laboratory = fields.Boolean("Is a laboratory") + is_lims_operator = fields.Boolean("Is a lab operator") + is_physician = fields.Boolean("Is a physician") diff --git a/lims/pyproject.toml b/lims/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/lims/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/lims/readme/CONFIGURE.md b/lims/readme/CONFIGURE.md new file mode 100644 index 0000000..4750eca --- /dev/null +++ b/lims/readme/CONFIGURE.md @@ -0,0 +1,72 @@ +The base LIMS module can be used with minimal initial configuration. +It also allows for many advanced features, which require +a more in-depth configuration. + +## Stages + +The stages are used to monitor progress. Stages can be +configured based on your company's specific business needs. A basic set +of stages comes pre-configured for use. + +1. Go to *LIMS \> Configuration \> Stages* +2. Create or edit a stage +3. Set the name for the stage. +4. Set the sequence order for the stage. +5. Select *Order* type to apply this stage to your analysis. +6. Additionally, you can set a color for the stage. + +## Laboratories + +You can manage different laboratories. + +## Advanced Configurations + +Additional features can be enabled in the General Settings panel for LIMS. + +1. Go to *LIMS \> Configuration \> Settings* +2. Enable additional options +3. Configure new options + +### Manage Teams + +Teams can be used to organize the processing of analysis +into groups. Different teams may have different workflows that an analysis +needs to follow. + +1. Go to *LIMS \> Configuration \> Operators \> Teams* +2. Create or select a team +3. Set the team name, description, and sequence + +You can now define custom stages for each team processing analysis. + +1. Go to *LIMS \> Configuration \> Stages* +2. Create or edit a stage +3. Select the teams for which this stage should be used + +### Manage Categories + +Categories are used to group operators and the type of analysis an operator can +do. + +1. Go to *LIMS \> Configuration \> Operators \> Categories* +2. Create or select a category +3. Set the name and description of category +4. Additionally, you can select a parent category if required + +### Manage Tags + +Tags can be used to filter and report on analysis + +1. Go to *LIMS \> Configuration \> Analysis \> Tags* +2. Create or select a tag +3. Set the tag name +4. Set a color index for the tag + +### Manage Templates + +Templates allow you to create standard templates for your analysis. + +1. Go to *LIMS \> Master Data \> Templates* +2. Create or select a template +3. Set the name +4. Set the standard order instructions diff --git a/lims/readme/CONTRIBUTORS.md b/lims/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..b634127 --- /dev/null +++ b/lims/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- Rodrigo Madrid Carmona \<\> +- Johannan Jasiel Luna García\<\> +- Maxime Chambreuil \<\> diff --git a/lims/readme/CREDITS.md b/lims/readme/CREDITS.md new file mode 100644 index 0000000..9646c07 --- /dev/null +++ b/lims/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +- Ganaderos Asociados de Querétaro \<\> diff --git a/lims/readme/DESCRIPTION.md b/lims/readme/DESCRIPTION.md new file mode 100644 index 0000000..14c1752 --- /dev/null +++ b/lims/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module is the base of the LIMS application based on LOINC. + +LOINC codes can be updated from here: https://loinc.org/downloads/ diff --git a/lims/readme/USAGE.md b/lims/readme/USAGE.md new file mode 100644 index 0000000..49c1df2 --- /dev/null +++ b/lims/readme/USAGE.md @@ -0,0 +1,19 @@ +To use this module, you need to: + +## Add LIMS Operators + +Operators are the people responsible for performing an analysis. +These operators may be subcontractors or the company's own employees. + +1. Go to *LIMS \> Master Data \> Operators* +2. Create an operator + +## Process Analysis + +Once you have established your data, you can begin processing analysis. + +1. Go to *LIMS \> Dashboard \> Analysis* +2. Create or select an analysis +3. Enter relevant details for the analysis +4. Process the analysis through each stage as defined by your business + requirements diff --git a/lims/security/ir.model.access.csv b/lims/security/ir.model.access.csv new file mode 100644 index 0000000..2379953 --- /dev/null +++ b/lims/security/ir.model.access.csv @@ -0,0 +1,26 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_lims_stage_lims_user,lims.stage.user,model_lims_stage,lims.group_lims_user_own,1,0,0,0 +access_lims_stage_lims_manager,lims.stage.manager,model_lims_stage,lims.group_lims_manager,1,1,1,1 +access_lims_loinc_code_user,lims.loinc.code.user,model_loinc_code,lims.group_lims_user_own,1,0,0,0 +access_lims_loinc_code_manager,lims.loinc.code.manager,model_loinc_code,lims.group_lims_manager,1,1,1,1 +access_lims_tag_lims_user,lims.tag.user,model_lims_tag,lims.group_lims_user_own,1,0,0,0 +access_lims_tag_lims_manager,lims.tag.manager,model_lims_tag,lims.group_lims_manager,1,1,1,1 +access_lims_specimen_user,lims.specimen.user,model_lims_specimen,lims.group_lims_user_own,1,1,0,0 +access_lims_specimen_manager,lims.specimen.manager,model_lims_specimen,lims.group_lims_manager,1,1,1,1 +access_lims_test_user,lims.test.user,model_lims_test,lims.group_lims_user_own,1,0,0,0 +access_lims_test_manager,lims.test.manager,model_lims_test,lims.group_lims_manager,1,1,1,0 +access_lims_order_user,lims.order.user,model_lims_order,lims.group_lims_user_own,1,1,1,0 +access_lims_order_test_user,lims.order.test.user,model_lims_order_test,lims.group_lims_user_own,1,1,1,0 +access_lims_batch_user,lims.batch.user,model_lims_batch,lims.group_lims_user_own,1,1,1,0 +access_lims_instrument_lims_user,lims.instrument.user,model_lims_instrument,lims.group_lims_user_own,1,0,0,0 +access_lims_instrument_lims_manager,lims.instrument.manager,model_lims_instrument,lims.group_lims_manager,1,1,1,1 +access_lims_category_user,lims.category.user,model_lims_category,lims.group_lims_user_own,1,0,0,0 +access_lims_category_manager,lims.category.manager,model_lims_category,lims.group_lims_manager,1,1,1,1 +access_lims_template_user,lims.template.user,model_lims_template,lims.group_lims_user_own,1,0,0,0 +access_lims_template_manager,lims.template.manager,model_lims_template,lims.group_lims_manager,1,1,1,1 +access_lims_team_user,lims.team.user,model_lims_team,lims.group_lims_user_own,1,0,0,0 +access_lims_team_manager,lims.team.manager,model_lims_team,lims.group_lims_manager,1,1,1,1 +access_lims_method_user,lims.method.user,model_lims_method,lims.group_lims_user_own,1,0,0,0 +access_lims_method_manager,lims.method.manager,model_lims_method,lims.group_lims_manager,1,1,1,1 +access_lims_result_user,lims.result.user,model_lims_result,lims.group_lims_user_own,1,1,1,0 +access_lims_result_manager,lims.result.manager,model_lims_result,lims.group_lims_manager,1,1,1,1 diff --git a/lims/security/ir_rule.xml b/lims/security/ir_rule.xml new file mode 100644 index 0000000..35b804c --- /dev/null +++ b/lims/security/ir_rule.xml @@ -0,0 +1,61 @@ + + + + LIMS Orders Entry + + + [('company_id', 'in', company_ids + [False])] + + + + LIMS Orders Entry (only own) + + [('company_id', 'in', company_ids + [False])] + + + + + LIMS Orders Entry + + [(1, '=', 1)] + + + + + LIMS Templates Entry + + + [('company_id', 'in', company_ids + [False])] + + + LIMS Teams Entry + + + [('company_id', 'in', company_ids + [False])] + + + LIMS Tags Entry + + + [('company_id', 'in', company_ids + [False])] + + + LIMS Categories Entry + + + [('company_id', 'in', company_ids + [False])] + + + LIMS Instruments Entry + + + [('company_id', 'in', company_ids + [False])] + + + LIMS Stage Entry + + + [('company_id', 'in', company_ids + [False])] + + diff --git a/lims/security/res_groups.xml b/lims/security/res_groups.xml new file mode 100644 index 0000000..61879ab --- /dev/null +++ b/lims/security/res_groups.xml @@ -0,0 +1,53 @@ + + + + User (only own documents) + + + + + + User + + + + + Manager + + + + + + + + Manage Laboratories + + + + Manage LIMS Teams + + + + Manage LIMS Operators Categories + + + + Manage LIMS Tags + + + + Manage LIMS Instruments + + + + Manage LIMS Templates + + + + Manage LIMS Batches + + + diff --git a/lims/static/description/icon.jpg b/lims/static/description/icon.jpg new file mode 100644 index 0000000..f71a538 Binary files /dev/null and b/lims/static/description/icon.jpg differ diff --git a/lims/static/description/icon.png b/lims/static/description/icon.png new file mode 100644 index 0000000..3fd26c6 Binary files /dev/null and b/lims/static/description/icon.png differ diff --git a/lims/static/description/icon.svg b/lims/static/description/icon.svg new file mode 100644 index 0000000..cef3082 --- /dev/null +++ b/lims/static/description/icon.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lims/static/description/index.html b/lims/static/description/index.html new file mode 100644 index 0000000..d06c290 --- /dev/null +++ b/lims/static/description/index.html @@ -0,0 +1,559 @@ + + + + + +Laboratory Information Management System (LIMS) + + + +
+

Laboratory Information Management System (LIMS)

+ + +

Beta License: AGPL-3 OCA/connector-lims Translate me on Weblate Try me on Runboat

+

This module is the base of the LIMS application based on LOINC.

+

LOINC codes can be updated from here: https://loinc.org/downloads/

+

Table of contents

+ +
+

Configuration

+

The base LIMS module can be used with minimal initial configuration. It +also allows for many advanced features, which require a more in-depth +configuration.

+
+

Stages

+

The stages are used to monitor progress. Stages can be configured based +on your company’s specific business needs. A basic set of stages comes +pre-configured for use.

+
    +
  1. Go to LIMS > Configuration > Stages
  2. +
  3. Create or edit a stage
  4. +
  5. Set the name for the stage.
  6. +
  7. Set the sequence order for the stage.
  8. +
  9. Select Order type to apply this stage to your analysis.
  10. +
  11. Additionally, you can set a color for the stage.
  12. +
+
+
+

Laboratories

+

You can manage different laboratories.

+
+
+

Advanced Configurations

+

Additional features can be enabled in the General Settings panel for +LIMS.

+
    +
  1. Go to LIMS > Configuration > Settings
  2. +
  3. Enable additional options
  4. +
  5. Configure new options
  6. +
+
+

Manage Teams

+

Teams can be used to organize the processing of analysis into groups. +Different teams may have different workflows that an analysis needs to +follow.

+
    +
  1. Go to LIMS > Configuration > Operators > Teams
  2. +
  3. Create or select a team
  4. +
  5. Set the team name, description, and sequence
  6. +
+

You can now define custom stages for each team processing analysis.

+
    +
  1. Go to LIMS > Configuration > Stages
  2. +
  3. Create or edit a stage
  4. +
  5. Select the teams for which this stage should be used
  6. +
+
+
+

Manage Categories

+

Categories are used to group operators and the type of analysis an +operator can do.

+
    +
  1. Go to LIMS > Configuration > Operators > Categories
  2. +
  3. Create or select a category
  4. +
  5. Set the name and description of category
  6. +
  7. Additionally, you can select a parent category if required
  8. +
+
+
+

Manage Tags

+

Tags can be used to filter and report on analysis

+
    +
  1. Go to LIMS > Configuration > Analysis > Tags
  2. +
  3. Create or select a tag
  4. +
  5. Set the tag name
  6. +
  7. Set a color index for the tag
  8. +
+
+
+

Manage Templates

+

Templates allow you to create standard templates for your analysis.

+
    +
  1. Go to LIMS > Master Data > Templates
  2. +
  3. Create or select a template
  4. +
  5. Set the name
  6. +
  7. Set the standard order instructions
  8. +
+
+
+
+
+

Usage

+

To use this module, you need to:

+
+

Add LIMS Operators

+

Operators are the people responsible for performing an analysis. These +operators may be subcontractors or the company’s own employees.

+
    +
  1. Go to LIMS > Master Data > Operators
  2. +
  3. Create an operator
  4. +
+
+
+

Process Analysis

+

Once you have established your data, you can begin processing analysis.

+
    +
  1. Go to LIMS > Dashboard > Analysis
  2. +
  3. Create or select an analysis
  4. +
  5. Enter relevant details for the analysis
  6. +
  7. Process the analysis through each stage as defined by your business +requirements
  8. +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Open Source Integrators
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The development of this module has been financially supported by:

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

max3903 jasiel-osi

+

This module is part of the OCA/connector-lims project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/lims/static/src/scss/team_dashboard.scss b/lims/static/src/scss/team_dashboard.scss new file mode 100644 index 0000000..dd4c0ea --- /dev/null +++ b/lims/static/src/scss/team_dashboard.scss @@ -0,0 +1,5 @@ +.o_kanban_view.o_kanban_ungrouped.o_kanban_dashboard.o_lims_team_kanban { + .o_kanban_record { + width: 308px; + } +} diff --git a/lims/tests/__init__.py b/lims/tests/__init__.py new file mode 100644 index 0000000..47b3df3 --- /dev/null +++ b/lims/tests/__init__.py @@ -0,0 +1,7 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common +from . import test_lims_order +from . import test_lims_order_test +from . import test_lims_batch diff --git a/lims/tests/common.py b/lims/tests/common.py new file mode 100644 index 0000000..c354ae9 --- /dev/null +++ b/lims/tests/common.py @@ -0,0 +1,125 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields +from odoo.tests import TransactionCase + + +class LIMSCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # === Partners === + cls.partner_specimen = cls.env.ref( + "base.res_partner_10", raise_if_not_found=False + ) + cls.partner_admin = cls.env.ref("base.partner_admin", raise_if_not_found=False) + cls.partner_laboratory = cls.env.ref( + "base.res_partner_1", raise_if_not_found=False + ) + cls.partner_operator = cls.env.ref( + "base.res_partner_2", raise_if_not_found=False + ) + cls.partner_operator_4 = cls.env.ref( + "base.res_partner_4", raise_if_not_found=False + ) + cls.partner_physician = cls.env.ref( + "base.res_partner_3", raise_if_not_found=False + ) + + # Assign LIMS role flags for partners + cls.partner_admin.write( + { + "is_laboratory": True, + "is_lims_operator": True, + "is_physician": True, + } + ) + cls.partner_laboratory.write( + { + "is_laboratory": True, + "is_lims_operator": False, + "is_physician": False, + } + ) + cls.partner_operator.write( + { + "is_laboratory": False, + "is_lims_operator": True, + "is_physician": False, + } + ) + cls.partner_operator_4.write( + { + "is_laboratory": False, + "is_lims_operator": True, + "is_physician": False, + } + ) + cls.partner_physician.write( + { + "is_laboratory": False, + "is_lims_operator": False, + "is_physician": True, + } + ) + + # === LIMS Entities === + cls.test_id = cls.env.ref( + "lims.lims_test_microbiology_parasitic_studies", raise_if_not_found=False + ) + + # Category + cls.lims_category = cls.env["lims.category"].create( + { + "name": "Test Category", + "description": "Functional test category", + } + ) + + # Team + cls.lims_team = cls.env["lims.team"].create( + { + "name": "Test Team", + "description": "Functional test team", + } + ) + + # Tag + cls.lims_tag = cls.env["lims.tag"].create( + { + "name": "Test Tag", + } + ) + + # Specimen + cls.lims_specimen = cls.env["lims.specimen"].create( + { + "name": "Test/Blood/007", + "partner_id": cls.partner_specimen.id, + "collection_date": fields.Date.today(), + } + ) + + # Template + cls.lims_template = cls.env["lims.template"].create( + { + "name": "Test/TMP/007", + "physician_id": cls.partner_physician.id, + "operator_id": cls.partner_operator_4.id, + "tag_ids": [(6, 0, [cls.lims_tag.id])], + "category_ids": [(6, 0, [cls.lims_category.id])], + "test_ids": [(6, 0, [cls.test_id.id])], + } + ) + + # Instrument + cls.instrument = cls.env["lims.instrument"].create( + { + "name": "Microscope", + "operator_id": cls.partner_operator.id, + "notes": """Used for viewing samples at a cellular level, + such as blood and tissue.""", + "laboratory_id": cls.partner_laboratory.id, + } + ) diff --git a/lims/tests/test_lims_batch.py b/lims/tests/test_lims_batch.py new file mode 100644 index 0000000..19f341e --- /dev/null +++ b/lims/tests/test_lims_batch.py @@ -0,0 +1,114 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import fields +from odoo.exceptions import ValidationError +from odoo.fields import Command + +from odoo.addons.lims.tests.common import LIMSCommon + + +class TestLIMSBatch(LIMSCommon): + def setUp(self): + super().setUp() + self.LimsOrder = self.env["lims.order"] + self.LimsBatch = self.env["lims.batch"] + # Reference order stages + self.stage_new = self.env.ref( + "lims.lims_stage_order_new", raise_if_not_found=False + ) + self.stage_completed = self.env.ref( + "lims.lims_stage_batch_completed", raise_if_not_found=False + ) + self.stage_cancelled = self.env.ref( + "lims.lims_stage_batch_cancelled", raise_if_not_found=False + ) + # Sample partner for test orders + self.partner_id = self.env.ref("base.res_partner_12", raise_if_not_found=False) + + # Create LIMS order + self.lims_order = self.LimsOrder.create( + { + "name": "Test Order", + "stage_id": self.stage_new.id, + "company_id": self.env.company.id, + "laboratory_id": self.partner_laboratory.id, + "team_id": self.lims_team.id, + "partner_id": self.partner_id.id, + "tag_ids": [(6, 0, [self.lims_tag.id])], + "description": "Test Order For UT", + "operator_id": self.partner_operator.id, + "physician_id": self.partner_physician.id, + "specimen_id": self.lims_specimen.id, + "scheduled_date": fields.Date.today() + timedelta(days=5), + "date": fields.Date.today(), + "test_ids": [ + Command.create( + { + "test_id": self.test_id.id, + "operator_id": self.partner_operator.id, + "scheduled_date": fields.Date.today() + timedelta(days=5), + "date": fields.Date.today(), + "todo": "Test TODO", + } + ) + ], + } + ) + # Create LIMS batch + self.lims_batch = self.LimsBatch.create( + { + "laboratory_id": self.partner_laboratory.id, + "description": "Test Order For UT", + "operator_id": self.partner_operator.id, + "team_id": self.lims_team.id, + "test_id": self.test_id.id, + "scheduled_date": fields.Date.today() + timedelta(days=5), + "test_ids": [(6, 0, self.lims_order.test_ids.ids)], + } + ) + + def test_create_and_read_group_stage_ids(self): + """Test batch creation and _read_group_stage_ids filtering by team.""" + batch = self.lims_batch + batch.stage_id.team_ids = [(6, 0, [batch.team_id.id])] + + stages = batch.with_context( + default_team_id=batch.team_id.id + )._read_group_stage_ids(batch.stage_id, domain=[]) + + self.assertIn(batch.stage_id, stages) + self.assertNotEqual(batch.name, "New") + self.assertTrue(batch.can_unlink()) + + def test_action_complete_changes_to_completed_stage(self): + """Test batch completion moves it to completed stage.""" + batch = self.lims_batch + + # Batch cannot complete if tests are not completed + with self.assertRaises(ValidationError): + batch.action_complete() + self.assertNotEqual(batch.stage_id, self.stage_completed) + + # Complete linked tests, then complete batch + batch.test_ids.action_complete() + batch.action_complete() + + self.assertEqual(batch.stage_id, self.stage_completed) + self.assertFalse(batch.can_unlink()) + + with self.assertRaises(ValidationError): + batch.unlink() + + def test_action_cancel_changes_to_cancelled_stage(self): + """Test cancelling batch moves it to cancelled stage.""" + batch = self.lims_batch + batch.action_cancel() + self.assertEqual(batch.stage_id, self.stage_cancelled) + + def test_onchange_test_ids_populates_fields(self): + """Test onchange_test_ids executes without errors.""" + batch = self.lims_batch + batch._onchange_test_ids() diff --git a/lims/tests/test_lims_order.py b/lims/tests/test_lims_order.py new file mode 100644 index 0000000..cfcfb8f --- /dev/null +++ b/lims/tests/test_lims_order.py @@ -0,0 +1,144 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import fields +from odoo.exceptions import UserError, ValidationError +from odoo.fields import Command + +from odoo.addons.lims.tests.common import LIMSCommon + + +class TestLIMSOrder(LIMSCommon): + def setUp(self): + super().setUp() + self.LimsOrder = self.env["lims.order"] + # Reference order stages + self.stage_new = self.env.ref( + "lims.lims_stage_order_new", raise_if_not_found=False + ) + self.stage_completed = self.env.ref( + "lims.lims_stage_order_completed", raise_if_not_found=False + ) + self.stage_cancelled = self.env.ref( + "lims.lims_stage_order_cancelled", raise_if_not_found=False + ) + # Sample partner for test orders + self.partner_id = self.env.ref("base.res_partner_12", raise_if_not_found=False) + + # Create LIMS order + self.lims_order = self.LimsOrder.create( + { + "name": "Test Order", + "stage_id": self.stage_new.id, + "company_id": self.env.company.id, + "laboratory_id": self.partner_laboratory.id, + "team_id": self.lims_team.id, + "partner_id": self.partner_id.id, + "tag_ids": [(6, 0, [self.lims_tag.id])], + "description": "Test Order For UT", + "operator_id": self.partner_operator.id, + "physician_id": self.partner_physician.id, + "specimen_id": self.lims_specimen.id, + "scheduled_date": fields.Date.today() + timedelta(days=5), + "date": fields.Date.today(), + "test_ids": [ + Command.create( + { + "test_id": self.test_id.id, + "operator_id": self.partner_operator.id, + "scheduled_date": fields.Date.today() + timedelta(days=5), + "date": fields.Date.today(), + "todo": "Test TODO", + } + ) + ], + } + ) + + def test_create_lims_order(self): + """Ensure a LIMS order is created correctly with default values.""" + order = self.lims_order + + self.assertTrue(order, "Order should be successfully created.") + self.assertEqual( + order.stage_id, self.stage_new, "Order should start in 'New' stage." + ) + self.assertNotEqual( + order.name, + "New", + "Order name should be auto-generated or custom, not 'New'.", + ) + self.assertTrue( + order.can_unlink(), "A new order should be deletable in the draft stage." + ) + order.stage_id.team_ids = [(6, 0, [order.team_id.id])] + stages = order.with_context( + **{"default_team_id": order.team_id.id} + )._read_group_stage_ids(order.stage_id, domain=[]) + self.assertIn(order.stage_id, stages) + + def test_action_complete_sets_completed_stage(self): + """Verify that completing an order updates its stage and locks deletion.""" + order = self.lims_order + order.action_complete() + + self.assertEqual( + order.stage_id, + self.stage_completed, + "Order should move to 'Completed' stage after completion.", + ) + self.assertFalse( + order.can_unlink(), "Completed orders should not be deletable." + ) + + # Attempt to unlink should raise a ValidationError + with self.assertRaises( + ValidationError, + msg="Deleting a completed order must raise a ValidationError.", + ): + order.unlink() + + def test_action_cancel_sets_cancelled_stage(self): + """Confirm that cancelling an order changes its stage to 'Cancelled'.""" + order = self.lims_order + order.action_cancel() + + self.assertEqual( + order.stage_id, + self.stage_cancelled, + "Order should move to 'Cancelled' stage after cancellation.", + ) + + def test_write_restrict_completed_stage_directly(self): + """Prevent direct transition to 'Completed' stage from Kanban view.""" + with self.assertRaises( + UserError, + msg="Direct Kanban move to completed stage should raise a UserError.", + ): + self.lims_order.with_context(default_stage_id=self.stage_new.id).write( + {"stage_id": self.stage_completed.id} + ) + + def test_onchange_template_id_updates_related_fields(self): + """Ensure that selecting a template updates operator, categories, and tests.""" + order = self.lims_order + self.assertFalse(order.template_id, "Template should initially be empty.") + + # Apply template and trigger onchange + order.write({"template_id": self.lims_template.id}) + order._onchange_template_id() + + # Validate updated fields + self.assertTrue( + order.template_id, "Template ID should be set after assignment." + ) + self.assertTrue( + order.category_ids, "Categories should be populated from the template." + ) + self.assertNotEqual( + order.operator_id, + self.partner_operator, + "Operator should change when applying a template.", + ) diff --git a/lims/tests/test_lims_order_test.py b/lims/tests/test_lims_order_test.py new file mode 100644 index 0000000..fd63a46 --- /dev/null +++ b/lims/tests/test_lims_order_test.py @@ -0,0 +1,95 @@ +# Copyright (C) 2025 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import fields +from odoo.exceptions import ValidationError +from odoo.fields import Command + +from odoo.addons.lims.tests.common import LIMSCommon + + +class TestLIMSOrderTest(LIMSCommon): + def setUp(self): + super().setUp() + self.LimsOrder = self.env["lims.order"] + # Reference order stages + self.stage_new = self.env.ref( + "lims.lims_stage_order_new", raise_if_not_found=False + ) + self.stage_completed = self.env.ref( + "lims.lims_stage_order_completed", raise_if_not_found=False + ) + self.stage_cancelled = self.env.ref( + "lims.lims_stage_order_cancelled", raise_if_not_found=False + ) + # Sample partner for test orders + self.partner_id = self.env.ref("base.res_partner_12", raise_if_not_found=False) + + # Create LIMS order + self.lims_order = self.LimsOrder.create( + { + "name": "Test Order", + "stage_id": self.stage_new.id, + "company_id": self.env.company.id, + "laboratory_id": self.partner_laboratory.id, + "team_id": self.lims_team.id, + "partner_id": self.partner_id.id, + "tag_ids": [(6, 0, [self.lims_tag.id])], + "description": "Test Order For UT", + "operator_id": self.partner_operator.id, + "physician_id": self.partner_physician.id, + "specimen_id": self.lims_specimen.id, + "scheduled_date": fields.Date.today() + timedelta(days=5), + "date": fields.Date.today(), + "test_ids": [ + Command.create( + { + "test_id": self.test_id.id, + "operator_id": self.partner_operator.id, + "scheduled_date": fields.Date.today() + timedelta(days=5), + "date": fields.Date.today(), + "todo": "Test TODO", + } + ) + ], + } + ) + + def test_create_and_read_group_stage_ids(self): + """Test creation and _read_group_stage_ids filtering by team.""" + order_test = self.lims_order.test_ids + + # Assign stage team for filtering + order_test.stage_id.team_ids = [(6, 0, [order_test.team_id.id])] + + # Apply context and test filtering + stages = order_test.with_context( + default_team_id=order_test.team_id.id + )._read_group_stage_ids(order_test.stage_id, domain=[]) + + self.assertIn(order_test.stage_id, stages) + self.assertNotEqual(order_test.name, "New") + self.assertTrue(order_test.can_unlink()) + + order_test.team_id._compute_order_count() + order_test.team_id._compute_order_need_assign_count() + order_test.team_id._compute_order_need_schedule_count() + + def test_action_complete_changes_to_completed_stage(self): + """Test completing order test moves it to completed stage.""" + order_test = self.lims_order.test_ids + order_test.action_complete() + + self.assertEqual(order_test.stage_id, self.stage_completed) + self.assertFalse(order_test.can_unlink()) + + with self.assertRaises(ValidationError): + order_test.unlink() + + def test_action_cancel_changes_to_cancelled_stage(self): + """Test cancelling order test moves it to cancelled stage.""" + order_test = self.lims_order.test_ids + order_test.action_cancel() + self.assertEqual(order_test.stage_id, self.stage_cancelled) diff --git a/lims/views/lims_batch.xml b/lims/views/lims_batch.xml new file mode 100644 index 0000000..5d84199 --- /dev/null +++ b/lims/views/lims_batch.xml @@ -0,0 +1,249 @@ + + + + lims.batch.form + lims.batch + +
+
+ +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + lims.batch.list + lims.batch + + + + + + + + + + + + + + + Orders + lims.batch + list,form,calendar + +

+ Create an Order. +

+
+
+ + + + lims.batch.search + lims.batch + + + + + + + + + + + + + + + + + + + + + + + + + + + Batches + lims.batch + calendar + + + + + + + + + + + + Batches + lims.batch + kanban,list,form,calendar + + +

+ Create a batch. +

+
+
+ + + + + lims.batch.graph + lims.batch + + + + + + + + + lims.batch.pivot + lims.batch + + + + + + + diff --git a/lims/views/lims_category.xml b/lims/views/lims_category.xml new file mode 100644 index 0000000..a53432d --- /dev/null +++ b/lims/views/lims_category.xml @@ -0,0 +1,53 @@ + + + + lims.category.list + lims.category + + + + + + + + + + lims.category.form + lims.category + +
+ + +
+
+
+ + + LIMS Category + lims.category + list,form + +

+ Add a LIMS Operator Category here. +

+
+
+
diff --git a/lims/views/lims_instrument.xml b/lims/views/lims_instrument.xml new file mode 100644 index 0000000..fcd121c --- /dev/null +++ b/lims/views/lims_instrument.xml @@ -0,0 +1,204 @@ + + + + lims.instrument.search + lims.instrument + + + + + + + + + + + + + + lims.instrument.list + lims.instrument + + + + + + + + + + + + lims.instrument.form + lims.instrument + +
+
+
+ + + + +
+
+ + + + LIMS Instruments + lims.instrument + list,form + + {'default_laboratory_id': + context.get('laboratory_id', False)} + + +

+ Add an instrument here. +

+
+
+ + + + lims.instrument.kanban + lims.instrument + + + + + + + + +
+
+
+ + + +
+
+ +
+
+ +
+
+
+
+ + +
+
+ + +
+
+
+ + + + + + + LIMS Instrument + lims.instrument + list,kanban,form + +

+ Add a LIMS Instrument here. +

+
+
+ + + + lims.instrument.graph + lims.instrument + + + + + + + + + lims.instrument.pivot + lims.instrument + + + + + + + + + Instruments + lims.instrument + graph,pivot + +

Instruments Report

+
+
+ diff --git a/lims/views/lims_method.xml b/lims/views/lims_method.xml new file mode 100644 index 0000000..0f15637 --- /dev/null +++ b/lims/views/lims_method.xml @@ -0,0 +1,49 @@ + + + lims.method.form + lims.method + +
+ +
+
+ + + + + + + + + + + +
+
+
+
+ + + lims.method.list + lims.method + + + + + + + + + + Methods + lims.method + list,form + +

Create a new method.

+
+
+
diff --git a/lims/views/lims_order.xml b/lims/views/lims_order.xml new file mode 100644 index 0000000..4867ca3 --- /dev/null +++ b/lims/views/lims_order.xml @@ -0,0 +1,368 @@ + + + + lims.order.form + lims.order + +
+
+ +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + lims.order.list + lims.order + + + + + + + + + + + + + Analysis + lims.order + list,form,calendar + +

+ Create an Analysis. +

+
+
+ + + + lims.order.kanban + lims.order + + + + + + + + + +
+
+ + +
+
+
+ +
+
+
+ + + +
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+ + + + + + + + + lims.order.search + lims.order + + + + + + + + + + + + + + + + + + + + + + + + + + + Analysis + lims.order + calendar + + + + + + + + + + + + Analysis + lims.order + kanban,list,form,calendar + + +

+ Create an Analysis. +

+
+
+ + + + lims.order.graph + lims.order + + + + + + + + + lims.order.pivot + lims.order + + + + + + + + + Analysis + lims.order + graph,pivot + +

Analysis Reports.

+
+
+ + + Analysis + lims.order + qweb-pdf + lims.report_lims_order + lims.report_lims_order + 'Analysis - %s' % (object.name) + + + diff --git a/lims/views/lims_order_test.xml b/lims/views/lims_order_test.xml new file mode 100644 index 0000000..d6f43e3 --- /dev/null +++ b/lims/views/lims_order_test.xml @@ -0,0 +1,332 @@ + + + + lims.order.test.form + lims.order.test + +
+
+ +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + lims.order.test.list + lims.order.test + + + + + + + + + + + + + + + + Tests + lims.order.test + list,form + +

+ Create a test. +

+
+
+ + + + lims.order.kanban + lims.order + + + + + + + + +
+
+ + +
+
+
+ +
+
+
+ + + +
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+ + + + + + + + + lims.order.test.search + lims.order.test + + + + + + + + + + + + + + + + + + + + + + + + + + + Tests + lims.order.test + list,form + + +

+ Create a test. +

+
+
+ + + + + lims.order.test.graph + lims.order.test + + + + + + + + + lims.order.test.pivot + lims.order.test + + + + + + + + + Tests + lims.order.test + graph,pivot + +

Tests Reports.

+
+
+ diff --git a/lims/views/lims_result.xml b/lims/views/lims_result.xml new file mode 100644 index 0000000..d052512 --- /dev/null +++ b/lims/views/lims_result.xml @@ -0,0 +1,102 @@ + + + lims.result.form + lims.result + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + lims.result.list + lims.result + + + + + + + + + + + + + + + + + + + Results + lims.result + list,form + +

Create a new result.

+
+
+ + + + lims.result.graph + lims.result + + + + + + + + + lims.result.pivot + lims.result + + + + + + + + + Results + lims.result + graph,pivot + +

Results Reports.

+
+
+
diff --git a/lims/views/lims_specimen.xml b/lims/views/lims_specimen.xml new file mode 100644 index 0000000..a47933d --- /dev/null +++ b/lims/views/lims_specimen.xml @@ -0,0 +1,69 @@ + + + + lims.specimen.list + lims.specimen + + + + + + + + + + lims.specimen.form + lims.specimen + +
+
+
+ +
+