Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 122 additions & 45 deletions base_tier_validation/models/res_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@

_logger = logging.getLogger(__name__)

# Default for the ``base_tier_validation.late_after_days`` system parameter
# used by the systray to split pending reviews into a "Late" bucket.
DEFAULT_LATE_AFTER_DAYS = 7


class Users(models.Model):
_inherit = "res.users"
Expand All @@ -19,60 +23,133 @@ class Users(models.Model):

@api.model
def review_user_count(self):
"""Counts driving the tier-review systray dropdown.

Each returned dict represents one model that has reviews touching
the current user and reports three buckets so the dropdown can
mirror Odoo's activity systray layout:

- ``late_count`` -- pending reviews older than the configured
*late* threshold (``base_tier_validation.late_after_days``,
default ``7``). The reviewer should prioritise these.
- ``pending_count`` -- pending reviews within the threshold.
Standard work the reviewer can act on today.
- ``future_count`` -- waiting reviews where the current user is
assigned as reviewer but the sequence has not yet promoted the
review to pending. "What is incoming for me".

The per-bucket record ids are returned alongside the counts so
clicking a bucket in the systray opens exactly those records.
"""
user_reviews = {}
user = self.env.user
user.review_ids._update_review_status()
domain = (
Domain("status", "=", "pending")
& Domain("can_review", "=", True)
& Domain("id", "in", user.review_ids.ids)
late_after_days = int(
self.env["ir.config_parameter"]
.sudo()
.get_param(
"base_tier_validation.late_after_days",
default=str(DEFAULT_LATE_AFTER_DAYS),
)
)
late_cutoff = fields.Datetime.subtract(
fields.Datetime.now(), days=late_after_days
)
# Fetch every actionable review touching this user (pending +
# waiting) in one read_group so we only pay the SQL cost once.
domain = Domain("status", "in", ["pending", "waiting"]) & Domain(
"id", "in", user.review_ids.ids
)
review_groups = self.env["tier.review"]._read_group(
domain=domain,
groupby=["model"],
aggregates=["id:recordset"],
)
for model, tier_review in review_groups:
Model = self.env[model]
# Skip Models not having Tier Validation enabled (example: was unistalled)
if tier_review and hasattr(Model, "can_review"):
records_domain = (
Domain("id", "in", tier_review.mapped("res_id"))
& Domain("validation_status", "!=", "rejected")
& Domain("can_review", "=", True)
)
try:
records = (
Model.with_user(user)
.with_context(active_test=False)
.search(records_domain)
)
except AccessError:
# The user is a reviewer on records of a model whose
# ir.model.access does not grant them read access (e.g.
# a tier definition pointing at account.move while the
# reviewer has no accounting group). Skip silently so
# the systray keeps working; the reviewer cannot act on
# these reviews anyway without read access.
_logger.debug(
"User %s has no read access to %s; skipping in systray.",
user.login,
model,
for model, tier_reviews in review_groups:
Model = self.env.get(model)
if Model is None or not hasattr(Model, "can_review"):
# Tier-validation has been uninstalled for this model
# since the reviews were created. Skip silently.
continue
pending_reviews = tier_reviews.filtered(
lambda r: r.status == "pending" and r.can_review
)
future_reviews = tier_reviews.filtered(lambda r: r.status == "waiting")
late_reviews = pending_reviews.filtered(
lambda r: r.create_date and r.create_date < late_cutoff
)
try:
# Resolve pending reviews to their underlying records,
# honouring ACL + record rules + the active flag.
pending_records = (
Model.with_user(user)
.with_context(active_test=False)
.search(
Domain("id", "in", pending_reviews.mapped("res_id"))
& Domain("validation_status", "!=", "rejected")
& Domain("can_review", "=", True)
)
continue
# Excludes any cancelled records depending on the structure of the model
if Model._state_field in Model._fields:
records = records.filtered(
lambda x: x[x._state_field] != x._cancel_state
)
future_records = (
Model.with_user(user)
.with_context(active_test=False)
.search(
Domain("id", "in", future_reviews.mapped("res_id"))
& Domain("validation_status", "!=", "rejected")
)
if records:
user_reviews[model] = {
"id": records[0].id,
"name": Model._description,
"model": model,
"active_field": "active" in Model._fields,
"icon": modules.module.get_module_icon(Model._original_module),
"type": "tier_review",
"pending_count": len(records),
}
)
except AccessError:
# Reviewer was assigned to a model they have no read
# access to. Drop it from the systray; the workflow is
# stuck for a config reason and #30 / #32 surface that
# elsewhere.
_logger.debug(
"User %s has no read access to %s; skipping in systray.",
user.login,
model,
)
continue
# Filter out cancelled records the same way the model does
# when it has a state/cancel convention.
if Model._state_field in Model._fields:
pending_records = pending_records.filtered(
lambda x: x[x._state_field] != x._cancel_state
)
future_records = future_records.filtered(
lambda x: x[x._state_field] != x._cancel_state
)
late_res_ids = set(late_reviews.mapped("res_id"))
late_records = pending_records.filtered(
lambda x, late_res_ids=late_res_ids: x.id in late_res_ids
)
pending_ontime_records = pending_records - late_records
if not (late_records or pending_ontime_records or future_records):
continue
first_record = (
late_records[:1] or pending_ontime_records[:1] or future_records[:1]
)
user_reviews[model] = {
"id": first_record.id,
"name": Model._description,
"model": model,
"active_field": "active" in Model._fields,
"icon": modules.module.get_module_icon(Model._original_module),
"type": "tier_review",
"late_count": len(late_records),
"pending_count": len(pending_ontime_records),
"future_count": len(future_records),
"late_ids": late_records.ids,
"pending_ids": pending_ontime_records.ids,
"future_ids": future_records.ids,
}
return list(user_reviews.values())

@api.model
def tier_review_dashboard_action(self):
"""Optional hook returning the action a downstream module wants the
systray's "Show all reviews" footer link to open. Default is
``False`` so no link is rendered. ``base_tier_validation_board``
(and any other dashboard module) overrides this to point at its
global review dashboard, matching the pattern of Odoo's
"View all activities" footer in the activity systray."""
return False
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,32 @@ export class TierReviewMenu extends Component {
}

async fetchSystrayReviewer() {
const groups = await this.orm.call("res.users", "review_user_count");
const [groups, dashboardAction] = await Promise.all([
this.orm.call("res.users", "review_user_count"),
this.orm.call("res.users", "tier_review_dashboard_action"),
]);
let total = 0;
for (const group of groups) {
total += group.pending_count || 0;
// Headline counter mirrors what the reviewer must act on
// *right now*: late + pending. Future is a heads-up only
// and shouldn't inflate the urgency badge.
total += (group.late_count || 0) + (group.pending_count || 0);
}
this.store.tierReviewCounter = total;
this.store.tierReviewGroups = groups;
// ``dashboardAction`` is False when no downstream module exposes
// a global review dashboard (default); a serialised action dict
// when one does (e.g. ``base_tier_validation_board``).
this.store.tierReviewDashboardAction = dashboardAction || null;
}

openDashboard() {
const action = this.store.tierReviewDashboardAction;
if (!action) {
return;
}
this.dropdown.close();
this.action.doAction(action, {clearBreadcrumbs: true});
}

availableViews() {
Expand All @@ -39,20 +58,35 @@ export class TierReviewMenu extends Component {
];
}

openReviewGroup(group) {
openReviewGroup(group, bucket = "pending") {
this.dropdown.close();
const context = {};
const domain = [["can_review", "=", true]];
// Per-bucket ids are pre-computed server-side so we just hand
// them to the action's domain. Empty buckets are no-ops.
const idsByBucket = {
late: group.late_ids || [],
pending: group.pending_ids || [],
future: group.future_ids || [],
};
const ids = idsByBucket[bucket] || [];
if (!ids.length) {
return;
}
const labelByBucket = {
late: "Late",
pending: "Pending",
future: "Future",
};
const domain = [["id", "in", ids]];
if (group.active_field) {
domain.push(["active", "in", [true, false]]);
}
const views = this.availableViews();

this.action.doAction(
{
context,
context: {},
domain,
name: group.name,
name: `${group.name} - ${labelByBucket[bucket] || bucket}`,
res_model: group.model,
search_view_id: [false],
type: "ir.actions.act_window",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
<div
class="o-mail-ActivityGroup list-group-item list-group-item-action d-flex p-2 cursor-pointer"
t-att-data-model_name="review.model"
t-on-click="() => this.openReviewGroup(review)"
t-on-click="() => this.openReviewGroup(review, 'pending')"
>
<img alt="Review" t-att-src="review.icon" />
<div class="flex-grow-1 overflow-hidden">
Expand All @@ -52,17 +52,39 @@
t-out="review.name"
/>
<div class="d-flex">
<span
t-attf-class="#{review.late_count ? 'btn btn-link text-danger' : 'text-muted'} py-0 px-2 text-truncate"
t-on-click.stop="() => this.openReviewGroup(review, 'late')"
>
<t t-out="review.late_count or 0" /> Late
</span>
<span
t-attf-class="#{review.pending_count ? 'btn btn-link' : 'text-muted'} py-0 px-2 text-truncate"
t-on-click.stop="() => this.openReviewGroup(review)"
t-on-click.stop="() => this.openReviewGroup(review, 'pending')"
>
<t
t-out="review.pending_count or 0"
/> Pending
</span>
<span
t-if="review.future_count"
class="btn btn-link py-0 px-2 ms-auto text-truncate"
t-on-click.stop="() => this.openReviewGroup(review, 'future')"
>
<t t-out="review.pending_count" /> Pending
</span>
<t t-out="review.future_count" /> Future
</span>
</div>
</div>
</div>
</t>
</div>
<div
t-if="store.tierReviewDashboardAction"
class="list-group-item list-group-item-action text-center py-2 cursor-pointer"
t-on-click="() => this.openDashboard()"
>
Show all reviews
</div>
</div>
</t>
</Dropdown>
Expand Down
Loading
Loading