diff --git a/spp_grm/README.rst b/spp_grm/README.rst new file mode 100644 index 000000000..e69de29bb diff --git a/spp_grm/__init__.py b/spp_grm/__init__.py new file mode 100644 index 000000000..91c5580fe --- /dev/null +++ b/spp_grm/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/spp_grm/__manifest__.py b/spp_grm/__manifest__.py new file mode 100644 index 000000000..5bee71469 --- /dev/null +++ b/spp_grm/__manifest__.py @@ -0,0 +1,48 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "OpenSPP - Grievance Redress Mechanism", + "summary": """ + Grievance redress mechanism module for OpenSPP""", + "version": "17.0.1.0.0", + "sequence": 1, + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/openspp-modules", + "license": "LGPL-3", + "development_status": "Beta", + "category": "OpenSPP", + "external_dependencies": {"python": []}, + "maintainers": ["jeremi", "gonzalesedwin1123"], + "depends": [ + "base", + "mail", + "portal", + "g2p_registry_base", + "g2p_registry_individual", + "g2p_registry_group", + "spp_area", + "spp_user_roles", + ], + "data": [ + "data/grm_data.xml", + "data/mail_alias.xml", + "data/mail_templates.xml", + "security/grm_security.xml", + "security/ir.model.access.csv", + "data/user_roles.xml", + "views/res_partner_views.xml", + "views/grm_ticket_menu.xml", + "views/grm_ticket_stage_views.xml", + "views/grm_ticket_category_views.xml", + "views/grm_ticket_channel_views.xml", + "views/grm_ticket_tag_views.xml", + "views/grm_ticket_views.xml", + "views/grm_portal_templates.xml", + ], + "assets": {}, + "demo": [], + "images": [], + "application": True, + "installable": True, + "auto_install": False, +} diff --git a/spp_grm/controllers/__init__.py b/spp_grm/controllers/__init__.py new file mode 100644 index 000000000..cd7c6814c --- /dev/null +++ b/spp_grm/controllers/__init__.py @@ -0,0 +1 @@ +from . import grm_portal diff --git a/spp_grm/controllers/grm_portal.py b/spp_grm/controllers/grm_portal.py new file mode 100644 index 000000000..652deaa2e --- /dev/null +++ b/spp_grm/controllers/grm_portal.py @@ -0,0 +1,54 @@ +import logging + +from odoo import http +from odoo.http import request + +from odoo.addons.portal.controllers.portal import CustomerPortal + +_logger = logging.getLogger(__name__) + + +class SPPGrmPortal(CustomerPortal): + @http.route(["/my/tickets", "/my/tickets/page/"], type="http", auth="user", website=True) + def portal_my_tickets(self, page=1, **kw): + partner = request.env.user.partner_id + ticket = request.env["spp.grm.ticket"] + domain = [("partner_id", "=", partner.id)] + + # Pagination logic + tickets = ticket.search(domain) + values = { + "tickets": tickets, + "page_name": "tickets", + } + return request.render("spp_grm.portal_my_tickets", values) + + @http.route(["/my/ticket/new"], type="http", auth="user", website=True) + def portal_ticket_new(self, **kw): + categories = request.env["spp.grm.ticket.category"].search([]) + channels = request.env["spp.grm.ticket.channel"].search([]) + return request.render( + "spp_grm.portal_create_ticket", + { + "categories": categories, + "channels": channels, + "page_name": "tickets", + "ticket": "new", + }, + ) + + @http.route(["/my/ticket/submit"], type="http", auth="user", website=True, csrf=True) + def portal_ticket_submit(self, **kw): + partner = request.env.user.partner_id + vals = { + "name": kw.get("ticket_name"), + "description": kw.get("description"), + "category_id": kw.get("category_id"), + "channel_id": request.env.ref("spp_grm.grm_ticket_channel_web").id, + "partner_id": partner.id, + } + ticket = request.env["spp.grm.ticket"].sudo().create(vals) + + ticket.send_ticket_confirmation_email(ticket) + + return request.redirect("/my/tickets") diff --git a/spp_grm/data/grm_data.xml b/spp_grm/data/grm_data.xml new file mode 100644 index 000000000..f28456c1a --- /dev/null +++ b/spp_grm/data/grm_data.xml @@ -0,0 +1,94 @@ + + + + + Grievance Redress Mechanism + Grievance Redress Mechanism (GRM) for OpenSPP. + 9 + + + + + + GRM Ticket Sequence + spp.grm.ticket.sequence + 6 + no_gap + + + %(range_year)s- + + + + + + 1 + New + True + False + + + + 2 + In Progress + False + False + + + + 3 + Awaiting + False + False + + + + 4 + Done + False + True + True + + + + + 5 + Cancelled + False + True + True + + + + + 6 + Rejected + False + True + True + + + + + + Web + + + Email + + + Phone + + + Other + + + + Ticket Created + spp.grm.ticket + + + Ticket created + + + diff --git a/spp_grm/data/mail_alias.xml b/spp_grm/data/mail_alias.xml new file mode 100644 index 000000000..49ab661f1 --- /dev/null +++ b/spp_grm/data/mail_alias.xml @@ -0,0 +1,15 @@ + + + + yourdomain.com + + + + + helpdesk + + everyone + 0 + + + diff --git a/spp_grm/data/mail_templates.xml b/spp_grm/data/mail_templates.xml new file mode 100644 index 000000000..c831adb02 --- /dev/null +++ b/spp_grm/data/mail_templates.xml @@ -0,0 +1,43 @@ + + + + Ticket Submission Confirmation + + Your Ticket: {{ object.number }} has been submitted + {{ (user.email or 'support@yourdomain.com') }} + {{ object.partner_id.email }} + + {{ object.partner_id.lang }} + +
+

+ Dear Registrant Name, + +

Thank you for reaching out to us. Your ticket has been successfully created with the following details:

+ +
    +
  • Ticket No.: 2024-00000
  • +
  • Ticket Name: Ticket Name
  • +
  • Description: Description
  • +
  • Category: Category
  • +
  • Channel: Channel
  • +
+ +

We will get back to you shortly. You can track the status of your ticket through the Ticket Portal.

+ +

Best regards,
+ The Support Team

+

+
+
+
+
diff --git a/spp_grm/data/user_roles.xml b/spp_grm/data/user_roles.xml new file mode 100644 index 000000000..a72bf7523 --- /dev/null +++ b/spp_grm/data/user_roles.xml @@ -0,0 +1,56 @@ + + + + Global Support Manager + global + This role can supervise and manage all support requests and activities across all areas. + + + + + + Global Support + global + This role is allowed to view and respond to support requests from any areas. + + + + + + Local Support + local + This role is allowed to view and respond to support requests only from their assigned area. + + + + diff --git a/spp_grm/models/__init__.py b/spp_grm/models/__init__.py new file mode 100644 index 000000000..a44ccc2c0 --- /dev/null +++ b/spp_grm/models/__init__.py @@ -0,0 +1,6 @@ +from . import grm_ticket +from . import grm_ticket_stage +from . import grm_ticket_tag +from . import grm_ticket_channel +from . import grm_ticket_category +from . import res_partner diff --git a/spp_grm/models/grm_ticket.py b/spp_grm/models/grm_ticket.py new file mode 100644 index 000000000..4f103e9c6 --- /dev/null +++ b/spp_grm/models/grm_ticket.py @@ -0,0 +1,242 @@ +import logging +from email.utils import parseaddr + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SPPGRMTicket(models.Model): + _name = "spp.grm.ticket" + _description = "Grievance Redress Mechanism Ticket" + _rec_name = "number" + _rec_names_search = ["number", "name"] + _order = "priority desc, sequence, number desc, id desc" + _mail_post_access = "read" + _inherit = ["mail.thread", "mail.activity.mixin"] + + @api.model + def message_new(self, msg_dict, custom_values=None): + """Create a new ticket from an inbound email""" + _logger.debug("Creating new ticket from email") + # Extract values from the email + subject = msg_dict.get("subject") or "No Subject" + body = msg_dict.get("body") or "No Content" + email_from = msg_dict.get("email_from") + email_address = parseaddr(email_from)[1] + _logger.debug(f"Email from: {email_address}") + # TODO: What to do if the registrant is a group? + partner = self.env["res.partner"].search([("email", "=", email_address)], limit=1) + if not partner: + self._send_custom_error_notification(email_address, subject, email_from) + _logger.warning( + "No matching registrant found for email: %s. Email processed but a ticket is not created." + % email_address + ) + + # Prepare default values for the new ticket + vals = { + "name": subject, + "description": body, + "partner_id": partner.id if partner else False, + "partner_email": email_from, + "channel_id": self.env.ref("spp_grm.grm_ticket_channel_email").id, + "priority": "1", # Default priority + } + + if custom_values: + vals.update(custom_values) + + # Create the new GRM ticket + ticket = super().message_new(msg_dict, custom_values=vals) + if ticket: + _logger.info(f"New ticket created from email: {ticket.number}") + else: + _logger.info("No ticket was created from email.") + return ticket + + def message_update(self, msg_dict, update_vals=None): + """Update an existing ticket from an inbound email reply""" + res = super().message_update(msg_dict, update_vals=update_vals) + _logger.info(f"Ticket {self.number} updated from email") + return res + + def _send_custom_error_notification(self, email_address, subject, email_from): + """Send a custom email notification to the sender of the email if no registrant is found. + :param email_address: The email address of the sender + :param subject: The subject of the email + :param email_from: The email address of the recipient + """ + mail_values = { + "subject": subject, + "body_html": """ +

Dear Sender,

+

We could not process your request because your email address %s is not + in our record of registrants.

+

Kind Regards

+ """ + % email_address, + "email_to": email_address, + "email_from": email_from, + } + + # Send the email + mail = self.env["mail.mail"].create(mail_values) + mail.send() + + def _default_stage_id(self): + stages = self.env["spp.grm.ticket.stage"].search([]) + if stages: + return stages[0].id + return None + + number = fields.Char(string="Ticket number", default="/", readonly=True) + name = fields.Char(string="Title", required=True) + description = fields.Html(required=True, sanitize_style=True) + user_id = fields.Many2one( + comodel_name="res.users", + string="Assigned user", + tracking=True, + index=True, + compute="_compute_user_id", + store=True, + readonly=False, + ) + stage_id = fields.Many2one( + comodel_name="spp.grm.ticket.stage", + string="Stage", + default=_default_stage_id, + store=True, + readonly=False, + ondelete="restrict", + tracking=True, + group_expand="_read_group_stage_ids", + copy=False, + index=True, + ) + partner_id = fields.Many2one( + comodel_name="res.partner", string="Registrant", required=True, domain="[('is_registrant', '=', True)]" + ) + partner_email = fields.Char(string="Email", related="partner_id.email", store=True) + last_stage_update = fields.Datetime(default=fields.Datetime.now) + assigned_date = fields.Datetime() + closed_date = fields.Datetime() + closed = fields.Boolean(related="stage_id.closed") + unattended = fields.Boolean(related="stage_id.unattended", store=True) + tag_ids = fields.Many2many(comodel_name="spp.grm.ticket.tag", string="Tags") + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + required=True, + default=lambda self: self.env.company, + ) + channel_id = fields.Many2one(comodel_name="spp.grm.ticket.channel", string="Channel") + category_id = fields.Many2one( + comodel_name="spp.grm.ticket.category", + string="Category", + ) + priority = fields.Selection( + selection=[ + ("0", "Low"), + ("1", "Medium"), + ("2", "High"), + ("3", "Very High"), + ], + default="1", + ) + attachment_ids = fields.One2many( + comodel_name="ir.attachment", + inverse_name="res_id", + domain=[("res_model", "=", "spp.grm.ticket")], + string="Media Attachments", + ) + color = fields.Integer(string="Color Index") + kanban_state = fields.Selection( + selection=[ + ("normal", "Default"), + ("done", "Ready for next stage"), + ("blocked", "Blocked"), + ], + ) + sequence = fields.Integer( + index=True, + default=10, + help="Gives the sequence order when displaying a list of tickets.", + ) + active = fields.Boolean(default=True) + + def name_get(self): + res = [] + for rec in self: + res.append((rec.id, rec.number + " - " + rec.name)) + return res + + def _creation_subtype(self): + return self.env.ref("spp_grm.grm_tck_created") + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + proceed = True + if not vals.get("partner_id"): + proceed = False + else: + if vals.get("number", "/") == "/": + vals["number"] = self._prepare_ticket_number() + _logger.debug(f"Creating ticket {vals['number']}") + if vals.get("user_id") and not vals.get("assigned_date"): + vals["assigned_date"] = fields.Datetime.now() + if proceed: + return super().create(vals_list) + + def copy(self, default=None): + self.ensure_one() + if default is None: + default = {} + if "number" not in default: + default["number"] = self._prepare_ticket_number() + res = super().copy(default) + return res + + def write(self, vals): + for _ticket in self: + now = fields.Datetime.now() + if vals.get("stage_id"): + stage = self.env["spp.grm.ticket.stage"].browse([vals["stage_id"]]) + vals["last_stage_update"] = now + if stage.closed: + vals["closed_date"] = now + if vals.get("user_id"): + vals["assigned_date"] = now + return super().write(vals) + + @api.model + def _read_group_stage_ids(self, stages, domain, order): + """Read group method for stage_id field.""" + return stages.search(domain, order=order) + + def assign_to_me(self): + self.write({"user_id": self.env.user.id}) + + def _prepare_ticket_number(self): + # Generate ticket number + return self.env["ir.sequence"].next_by_code("spp.grm.ticket.sequence") + + def get_portal_url(self): + """Get the URL for the ticket's portal page.""" + self.ensure_one() + return "/my/tickets/%s" % self.id + + def send_ticket_confirmation_email(self, ticket): + """Send the ticket submission confirmation email.""" + template = self.env.ref("spp_grm.ticket_submission_confirmation", raise_if_not_found=False) + if template: + template.sudo().send_mail( + ticket.id, + force_send=True, + email_values={ + "email_to": ticket.partner_id.email, + }, + ) + else: + _logger.warning("Email template not found: spp_grm.ticket_submission_confirmation") diff --git a/spp_grm/models/grm_ticket_category.py b/spp_grm/models/grm_ticket_category.py new file mode 100644 index 000000000..164858ce3 --- /dev/null +++ b/spp_grm/models/grm_ticket_category.py @@ -0,0 +1,21 @@ +from odoo import fields, models + + +class SPPGRMCategory(models.Model): + _name = "spp.grm.ticket.category" + _description = "Grievance Redress Mechanism Ticket Category" + _order = "sequence, id" + + sequence = fields.Integer(default=10) + active = fields.Boolean( + default=True, + ) + name = fields.Char( + required=True, + translate=True, + ) + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + default=lambda self: self.env.company, + ) diff --git a/spp_grm/models/grm_ticket_channel.py b/spp_grm/models/grm_ticket_channel.py new file mode 100644 index 000000000..caf88e24b --- /dev/null +++ b/spp_grm/models/grm_ticket_channel.py @@ -0,0 +1,19 @@ +from odoo import fields, models + + +class SPPGRMTicketChannel(models.Model): + _name = "spp.grm.ticket.channel" + _description = "Grievance Redress Mechanism Ticket Channel" + _order = "sequence, id" + + sequence = fields.Integer(default=10) + name = fields.Char( + required=True, + translate=True, + ) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + default=lambda self: self.env.company, + ) diff --git a/spp_grm/models/grm_ticket_stage.py b/spp_grm/models/grm_ticket_stage.py new file mode 100644 index 000000000..fcaaa585f --- /dev/null +++ b/spp_grm/models/grm_ticket_stage.py @@ -0,0 +1,34 @@ +from odoo import api, fields, models + + +class SPPGRMTicketStage(models.Model): + _name = "spp.grm.ticket.stage" + _description = "Grievance Redress Mechanism Ticket Stage" + _order = "sequence, id" + + name = fields.Char(string="Stage Name", required=True, translate=True) + description = fields.Html(translate=True, sanitize_style=True) + sequence = fields.Integer(default=1) + active = fields.Boolean(default=True) + unattended = fields.Boolean() + closed = fields.Boolean() + mail_template_id = fields.Many2one( + comodel_name="mail.template", + string="Email Template", + domain=[("model", "=", "helpdesk.ticket")], + help="If set an email will be sent to the " "customer when the ticket" "reaches this step.", + ) + fold = fields.Boolean( + string="Folded in Kanban", + help="This stage is folded in the kanban view " "when there are no records in that stage " "to display.", + ) + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + default=lambda self: self.env.company, + ) + + @api.onchange("closed") + def _onchange_closed(self): + if not self.closed: + self.close_from_portal = False diff --git a/spp_grm/models/grm_ticket_tag.py b/spp_grm/models/grm_ticket_tag.py new file mode 100644 index 000000000..73cbddd84 --- /dev/null +++ b/spp_grm/models/grm_ticket_tag.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class SPPGRMTicketTag(models.Model): + _name = "spp.grm.ticket.tag" + _description = "Grievance Redress Mechanism Ticket Tag" + + name = fields.Char() + color = fields.Integer(string="Color Index") + active = fields.Boolean(default=True) + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + default=lambda self: self.env.company, + ) diff --git a/spp_grm/models/res_partner.py b/spp_grm/models/res_partner.py new file mode 100644 index 000000000..fde8aa004 --- /dev/null +++ b/spp_grm/models/res_partner.py @@ -0,0 +1,36 @@ +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + grm_ticket_ids = fields.One2many( + comodel_name="spp.grm.ticket", + inverse_name="partner_id", + string="Related Tickets", + ) + + grm_ticket_count = fields.Integer(compute="_compute_grm_ticket_count", string="Ticket Count") + + grm_ticket_active_count = fields.Integer(compute="_compute_grm_ticket_count", string="Active Ticket Count") + + grm_ticket_count_string = fields.Char(compute="_compute_grm_ticket_count", string="Tickets") + + def _compute_grm_ticket_count(self): + for record in self: + ticket_ids = self.env["spp.grm.ticket"].search([("partner_id", "child_of", record.id)]) + record.grm_ticket_count = len(ticket_ids) + record.grm_ticket_active_count = len(ticket_ids.filtered(lambda ticket: not ticket.stage_id.closed)) + count_active = record.grm_ticket_active_count + count = record.grm_ticket_count + record.grm_ticket_count_string = f"{count_active} / {count}" + + def action_view_grm_tickets(self): + return { + "name": "Tickets", # self.name, + "view_mode": "tree,form", + "res_model": "spp.grm.ticket", + "type": "ir.actions.act_window", + "domain": [("partner_id", "child_of", self.id)], + "context": self.env.context, + } diff --git a/spp_grm/pyproject.toml b/spp_grm/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/spp_grm/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_grm/security/grm_security.xml b/spp_grm/security/grm_security.xml new file mode 100644 index 000000000..1966d4688 --- /dev/null +++ b/spp_grm/security/grm_security.xml @@ -0,0 +1,22 @@ + + + + + User + + + + Manager + + + + + + + User Own Tickets + + [('user_id', '=', user.id)] + + + + diff --git a/spp_grm/security/ir.model.access.csv b/spp_grm/security/ir.model.access.csv new file mode 100644 index 000000000..a5c28a715 --- /dev/null +++ b/spp_grm/security/ir.model.access.csv @@ -0,0 +1,24 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_grm_ticket_manager,GRM Ticket Manager Access,model_spp_grm_ticket,group_grm_manager,1,1,1,1 +access_spp_grm_ticket_stage_manager,GRM Ticket Stage Manager Access,model_spp_grm_ticket_stage,group_grm_manager,1,1,1,1 +access_spp_grm_ticket_tag_manager,GRM Ticket Tags Manager Access,model_spp_grm_ticket_tag,group_grm_manager,1,1,1,1 +access_spp_grm_ticket_channel_manager,GRM Ticket Channels Manager Access,model_spp_grm_ticket_channel,group_grm_manager,1,1,1,1 +access_spp_grm_ticket_category_manager,GRM Ticket Category Manager Access,model_spp_grm_ticket_category,group_grm_manager,1,1,1,1 + +access_spp_grm_ticket_user,GRM Ticket User Access,model_spp_grm_ticket,group_grm_user,1,1,1,0 +access_spp_grm_ticket_stage_user,GRM Ticket Stage User Access,model_spp_grm_ticket_stage,group_grm_user,1,0,0,0 +access_spp_grm_ticket_tag_user,GRM Ticket Tags User Access,model_spp_grm_ticket_tag,group_grm_user,1,1,1,1 +access_spp_grm_ticket_channel_user,GRM Ticket Channels User Access,model_spp_grm_ticket_channel,group_grm_user,1,0,0,0 +access_spp_grm_ticket_category_user,GRM Ticket Category User Access,model_spp_grm_ticket_category,group_grm_user,1,0,0,0 + +access_spp_grm_ticket_base_user,GRM Ticket Base User Access,model_spp_grm_ticket,base.group_user,1,1,1,0 +access_spp_grm_ticket_stage_base_user,GRM Ticket Stage Base User Access,model_spp_grm_ticket_stage,base.group_user,1,0,0,0 +access_spp_grm_ticket_tag_base_user,GRM Ticket Tags Base User Access,model_spp_grm_ticket_tag,base.group_user,1,1,1,0 +access_spp_grm_ticket_channel_base_user,GRM Ticket Channels Base User Access,model_spp_grm_ticket_channel,base.group_user,1,0,0,0 +access_spp_grm_ticket_category_base_user,GRM Ticket Category Base User Access,model_spp_grm_ticket_category,base.group_user,1,0,0,0 + +access_spp_grm_ticket_portal_user,GRM Ticket Portal User Access,model_spp_grm_ticket,base.group_portal,1,1,1,0 +access_spp_grm_ticket_stage_portal_user,GRM Ticket Stage Portal User Access,model_spp_grm_ticket_stage,base.group_portal,1,0,0,0 +access_spp_grm_ticket_tag_portal_user,GRM Ticket Tags Portal User Access,model_spp_grm_ticket_tag,base.group_portal,1,1,1,0 +access_spp_grm_ticket_channel_portal_user,GRM Ticket Channels Portal User Access,model_spp_grm_ticket_channel,base.group_portal,1,0,0,0 +access_spp_grm_ticket_category_portal_user,GRM Ticket Category Portal User Access,model_spp_grm_ticket_category,base.group_portal,1,0,0,0 diff --git a/spp_grm/static/description/icon.png b/spp_grm/static/description/icon.png new file mode 100644 index 000000000..35f8fec26 Binary files /dev/null and b/spp_grm/static/description/icon.png differ diff --git a/spp_grm/static/src/img/ticket.svg b/spp_grm/static/src/img/ticket.svg new file mode 100644 index 000000000..51d1968db --- /dev/null +++ b/spp_grm/static/src/img/ticket.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/spp_grm/tests/__init__.py b/spp_grm/tests/__init__.py new file mode 100644 index 000000000..133c82356 --- /dev/null +++ b/spp_grm/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_grm_ticket +from . import test_grm_ticket_stage +from . import test_res_partner diff --git a/spp_grm/tests/test_grm_ticket.py b/spp_grm/tests/test_grm_ticket.py new file mode 100644 index 000000000..1000dbc69 --- /dev/null +++ b/spp_grm/tests/test_grm_ticket.py @@ -0,0 +1,89 @@ +from odoo import fields +from odoo.tests.common import TransactionCase + + +class SPPGRMTicketTests(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ticket_stage_open = cls.env["spp.grm.ticket.stage"].create({"name": "Open", "closed": False}) + cls.ticket_stage_closed = cls.env["spp.grm.ticket.stage"].create({"name": "Closed", "closed": True}) + cls.partner_1 = cls.env["res.partner"].create( + { + "name": "Test Partner 1", + } + ) + cls.partner_2 = cls.env["res.partner"].create( + { + "name": "Test Partner 2", + } + ) + cls.ticket = cls.env["spp.grm.ticket"].create( + { + "name": "Test Ticket", + "description": "Test Description", + "partner_id": cls.partner_1.id, + "stage_id": cls.ticket_stage_open.id, + } + ) + + def test_ticket_creation_number_generation(self): + """Test creation with default '/' number triggers number generation.""" + new_ticket = self.env["spp.grm.ticket"].create( + { + "name": "New Ticket", + "description": "New Description", + "partner_id": self.partner_2.id, + "number": "/", + } + ) + self.assertNotEqual(new_ticket.number, "/") + + def test_ticket_creation_user_assignment(self): + """Test creation where user_id is set but assigned_date is not.""" + new_ticket = self.env["spp.grm.ticket"].create( + { + "name": "Assigned Ticket", + "description": "Assigned Description", + "partner_id": self.partner_2.id, + "user_id": self.env.ref("base.user_admin").id, + } + ) + self.assertEqual(new_ticket.user_id, self.env.ref("base.user_admin")) + self.assertIsNotNone(new_ticket.assigned_date) + + def test_ticket_copy_number_generation(self): + """Test copying a ticket generates a new number.""" + copied_ticket = self.ticket.copy() + self.assertNotEqual(copied_ticket.number, self.ticket.number) + + def test_ticket_copy_custom_number(self): + """Test copying a ticket with a provided number in default.""" + copied_ticket = self.ticket.copy(default={"number": "CUSTOM123"}) + self.assertEqual(copied_ticket.number, "CUSTOM123") + + def test_ticket_write_stage_update(self): + """Test writing stage_id updates last_stage_update and closed_date (if closed).""" + now = fields.Datetime.now() + + self.ticket.write({"stage_id": self.ticket_stage_closed.id}) + self.assertEqual(self.ticket.stage_id, self.ticket_stage_closed) + self.assertEqual(self.ticket.closed_date.date(), now.date()) + self.assertEqual(self.ticket.last_stage_update.date(), now.date()) + + def test_ticket_write_user_assignment(self): + """Test writing user_id updates assigned_date.""" + now = fields.Datetime.now() + + self.ticket.write({"user_id": self.env.ref("base.user_admin").id}) + self.assertEqual(self.ticket.user_id, self.env.ref("base.user_admin")) + self.assertEqual(self.ticket.assigned_date.date(), now.date()) + + def test_ticket_write_no_stage_or_user_change(self): + """Test that writing without stage_id or user_id does not alter dates.""" + original_last_stage_update = self.ticket.last_stage_update + original_assigned_date = self.ticket.assigned_date + + self.ticket.write({"name": "Updated Ticket Name"}) + self.assertEqual(self.ticket.last_stage_update, original_last_stage_update) + self.assertEqual(self.ticket.assigned_date, original_assigned_date) diff --git a/spp_grm/tests/test_grm_ticket_stage.py b/spp_grm/tests/test_grm_ticket_stage.py new file mode 100644 index 000000000..71b64c4f7 --- /dev/null +++ b/spp_grm/tests/test_grm_ticket_stage.py @@ -0,0 +1,43 @@ +from odoo.tests.common import TransactionCase + + +class SPPGRMTicketStageTests(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.stage = cls.env["spp.grm.ticket.stage"].create( + { + "name": "Test Stage", + "sequence": 1, + "active": True, + } + ) + + def stage_creation(self): + new_stage = self.env["spp.grm.ticket.stage"].create( + { + "name": "New Stage", + "sequence": 2, + "active": False, + } + ) + self.assertEqual(new_stage.name, "New Stage") + + def stage_sequence_order(self): + self.assertEqual(self.stage.sequence, 1) + + def stage_active_status(self): + self.assertTrue(self.stage.active) + + def stage_unattended_status(self): + self.assertFalse(self.stage.unattended) + + def stage_closed_status(self): + self.assertFalse(self.stage.closed) + + def stage_folded_status(self): + self.assertFalse(self.stage.fold) + + def stage_onchange_closed(self): + self.stage.write({"closed": True}) + self.assertFalse(self.stage.fold) diff --git a/spp_grm/tests/test_res_partner.py b/spp_grm/tests/test_res_partner.py new file mode 100644 index 000000000..df5218176 --- /dev/null +++ b/spp_grm/tests/test_res_partner.py @@ -0,0 +1,35 @@ +from odoo.tests.common import TransactionCase + + +class ResPartnerTicketTests(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create( + { + "name": "Test Partner", + } + ) + cls.ticket = cls.env["spp.grm.ticket"].create( + { + "name": "Test Ticket", + "partner_id": cls.partner.id, + } + ) + + def partner_ticket_relation(self): + self.assertIn(self.ticket, self.partner.grm_ticket_ids) + + def partner_ticket_count(self): + self.assertEqual(self.partner.grm_ticket_count, 1) + + def partner_active_ticket_count(self): + self.assertEqual(self.partner.grm_ticket_active_count, 1) + + def partner_ticket_count_string(self): + self.assertEqual(self.partner.grm_ticket_count_string, "1 / 1") + + def partner_action_view_grm_tickets(self): + action = self.partner.action_view_grm_tickets() + self.assertEqual(action["res_model"], "spp.grm.ticket") + self.assertEqual(action["domain"], [("partner_id", "child_of", self.partner.id)]) diff --git a/spp_grm/views/grm_portal_templates.xml b/spp_grm/views/grm_portal_templates.xml new file mode 100644 index 000000000..87060fe32 --- /dev/null +++ b/spp_grm/views/grm_portal_templates.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + +