diff --git a/enferno/admin/models/Activity.py b/enferno/admin/models/Activity.py index 1989242e5..4654efecd 100644 --- a/enferno/admin/models/Activity.py +++ b/enferno/admin/models/Activity.py @@ -76,10 +76,10 @@ def to_dict(self) -> dict[str, Any]: # helper static method to create different type of activities (tags) @staticmethod def create( - user: t.id, + user: "User", action: str, status: str, - subject: str, + subject: dict, model: str, details: Optional[str] = None, ) -> None: @@ -87,10 +87,10 @@ def create( Create an activity. Args: - - user: the user id. + - user: the user object. - action: the action. - status: the status. - - subject: the subject. + - subject: the subject dict (e.g. from to_mini()). - model: the model. - details: the details. """ diff --git a/enferno/admin/models/UserHistory.py b/enferno/admin/models/UserHistory.py new file mode 100644 index 000000000..bccd54c39 --- /dev/null +++ b/enferno/admin/models/UserHistory.py @@ -0,0 +1,46 @@ +import json +from typing import Any + +from sqlalchemy import JSON + +from enferno.extensions import db +from enferno.utils.base import BaseMixin +from enferno.utils.date_helper import DateHelper +from enferno.utils.logging_utils import get_logger + +logger = get_logger() + + +class UserHistory(db.Model, BaseMixin): + """ + SQL Alchemy model for user revisions. + Access is restricted to Admin role at the endpoint level. + """ + + id = db.Column(db.Integer, primary_key=True) + target_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), index=True) + target_user = db.relationship( + "User", + backref=db.backref("history", order_by="UserHistory.updated_at"), + foreign_keys=[target_user_id], + ) + data = db.Column(JSON) + # user tracking - who made the change + user_id = db.Column(db.Integer, db.ForeignKey("user.id")) + user = db.relationship("User", foreign_keys=[user_id]) + + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the user revision.""" + return { + "id": self.id, + "data": self.data, + "created_at": DateHelper.serialize_datetime(self.created_at), + "user": self.user.to_compact() if self.user else None, + } + + def to_json(self) -> str: + """Return a JSON representation of the user revision.""" + return json.dumps(self.to_dict(), sort_keys=True) + + def __repr__(self): + return "".format(self.id, self.target_user_id) diff --git a/enferno/admin/models/__init__.py b/enferno/admin/models/__init__.py index dcdd02047..a89613acc 100644 --- a/enferno/admin/models/__init__.py +++ b/enferno/admin/models/__init__.py @@ -43,3 +43,4 @@ from .Source import Source from .WorkflowStatus import WorkflowStatus from .Notification import Notification +from .UserHistory import UserHistory diff --git a/enferno/admin/templates/admin/actors.html b/enferno/admin/templates/admin/actors.html index f843571e0..d0d4c1800 100644 --- a/enferno/admin/templates/admin/actors.html +++ b/enferno/admin/templates/admin/actors.html @@ -85,7 +85,7 @@ - {% if current_user.roles_in(['Admin','DA']) %} + {% if current_user.roles_in(['Admin','Analyst']) %} {{ _('New Actor') }} {% endif %} @@ -568,7 +568,7 @@ {title: "{{_('ID')}}", value: "id", width: 12, sortable: false}, {title: "{{_('Type')}}", value: "type", width: 20, sortable: false}, {title: "{{_('Name')}}", value: "name", width: 300, sortable: false}, - {% if (current_user.has_role('Admin') or current_user.has_role('DA')) %} + {% if (current_user.has_role('Admin') or current_user.has_role('Analyst')) %} {title: "{{_('Assigned To')}}", value: "assigned_to.name", width: 130, sortable: false}, {title: "{{_('Access Groups')}}", value: "roles", width: 130, sortable: false}, {% endif %} @@ -1298,7 +1298,7 @@ searchUsers: debounce(function (evt) { this.searchLoading[evt.target.dataset.loader] = true; - api.get(`/admin/api/users/?q=${evt.target.value}`).then(response => { + api.get(`/admin/api/users/assignable?q=${evt.target.value}`).then(response => { this.users = response.data.items; this.searchLoading[evt.target.dataset.loader] = false; }); @@ -1347,7 +1347,7 @@ }, bulkAllowed() { - return this.has_role(this.currentUser, 'Admin') || this.has_role(this.currentUser, 'Mod'); + return this.has_role(this.currentUser, 'Admin') || this.has_role(this.currentUser, 'Moderator'); }, exportAllowed() { @@ -1371,7 +1371,7 @@ if (this.has_role(this.currentUser, 'Admin')) { return true; } - if (!this.has_role(this.currentUser, 'DA')) { + if (!this.has_role(this.currentUser, 'Analyst')) { return false; } const statuses = ['Human Created', 'Assigned', 'Updated', 'Peer Reviewed', 'Revisited']; @@ -1405,7 +1405,7 @@ } if (this.currentUser.can_self_assign) { - if ((!actor.assigned_to) || (actor.assigned_to && !actor.assigned_to.active)) { + if ((!actor.assigned_to) || (actor.assigned_to && actor.assigned_to.status !== 'active')) { return true } diff --git a/enferno/admin/templates/admin/bulletins.html b/enferno/admin/templates/admin/bulletins.html index 000895d0b..0315ab173 100644 --- a/enferno/admin/templates/admin/bulletins.html +++ b/enferno/admin/templates/admin/bulletins.html @@ -93,7 +93,7 @@ > - {% if current_user.roles_in(['Admin','DA']) %} + {% if current_user.roles_in(['Admin','Analyst']) %} - {% if current_user.roles_in(['Admin','DA']) %} + {% if current_user.roles_in(['Admin','Analyst']) %} {{ _('New Incident') }} @@ -535,7 +535,7 @@ headers: [ {title: "{{_('ID')}}", value: "id", width: 12, sortable: false}, {title: "{{_('Title')}}", value: "title", width: 300, sortable: false}, - {% if (current_user.has_role('Admin') or current_user.has_role('DA')) %} + {% if (current_user.has_role('Admin') or current_user.has_role('Analyst')) %} {title: "{{_('Assigned To')}}", value: "assigned_to.name", width: 130, sortable: false}, {title: "{{_('Access Groups')}}", value: "roles", width: 130, sortable: false}, {% endif %} @@ -1122,7 +1122,7 @@ searchUsers: debounce(function (evt) { this.searchLoading[evt.target.dataset.loader] = true; - api.get(`/admin/api/users/?q=${evt.target.value}`).then(response => { + api.get(`/admin/api/users/assignable?q=${evt.target.value}`).then(response => { this.users = response.data.items; this.searchLoading[evt.target.dataset.loader] = false; }); @@ -1175,7 +1175,7 @@ if (this.has_role(this.currentUser, 'Admin')) { return true; } - if (this.has_role(this.currentUser, 'Mod')) { + if (this.has_role(this.currentUser, 'Moderator')) { return true; } return false; @@ -1200,7 +1200,7 @@ if (this.has_role(this.currentUser, 'Admin')) { return true; } - if (!this.has_role(this.currentUser, 'DA')) { + if (!this.has_role(this.currentUser, 'Analyst')) { return false; } const statuses = ['Human Created', 'Assigned', 'Updated', 'Peer Reviewed', 'Revisited']; @@ -1236,7 +1236,7 @@ if (this.currentUser.can_self_assign) { - if ((!incident.assigned_to) || (incident.assigned_to && !incident.assigned_to.active)) { + if ((!incident.assigned_to) || (incident.assigned_to && incident.assigned_to.status !== 'active')) { return true } diff --git a/enferno/admin/templates/admin/jsapi.jinja2 b/enferno/admin/templates/admin/jsapi.jinja2 index 3e52898e6..fd09f9b10 100644 --- a/enferno/admin/templates/admin/jsapi.jinja2 +++ b/enferno/admin/templates/admin/jsapi.jinja2 @@ -83,6 +83,34 @@ allUnsavedEditsHaveBeenDiscarded_: "{{ _('All unsaved edits in the form have bee reviewAndConfirmChanges_: "{{ _('Review & Confirm Changes') }}", youreAboutToDeleteAField_: "{{ _('You\'re about to delete a field') }}", fieldsSavedSuccessfully_: "{{ _('Fields saved successfully') }}", +editUser_: "{{ _('Edit User') }}", +systemRole_: "{{ _('System Role') }}", +accessRole_: "{{ _('Access Role') }}", +manageAccount_: "{{ _('Manage Account') }}", +disabled_: "{{ _('Disabled') }}", +twoFactorAuthentication_: "{{ _('Two-Factor Authentication') }}", +youreAboutToEndThisSession_: "{{ _('You\'re about to end this session') }}", +afterThisActionTheUserWillBeSignedOut_: "{{ _('After this action, the user will be signed out from this device. This will not affect other active sessions, user data, or settings.') }}", +doYouWantToContinue_: "{{ _('Do you want to continue?') }}", +endSession_: "{{ _('End Session') }}", +view_: "{{ _('View') }}", +reactivateAccount_: "{{ _('Reactivate Account') }}", +suspendAccount_: "{{ _('Suspend Account') }}", +enableAccount_: "{{ _('Enable Account') }}", +disableAccount_: "{{ _('Disable Account') }}", +assignSystemRole_: "{{ _('Assign System Role') }}", +assignAccessRole_: "{{ _('Assign Access Role') }}", +assignUserPermissions_: "{{ _('Assign User Permissions') }}", +userHasBeenSuspended_: (name) => "{{ _('User \'{name}\' has been suspended.') }}".replace("{name}", name), +userHasBeenDisabled_: (name) => "{{ _('User \'{name}\' has been disabled.') }}".replace("{name}", name), +userHasBeenReactivated_: (name) => "{{ _('User \'{name}\' has been reactivated.') }}".replace("{name}", name), +userHasBeenEnabled_: (name) => "{{ _('User \'{name}\' has been enabled.') }}".replace("{name}", name), +revisionHistory_: "{{ _('Revision History') }}", +desktop_: "{{ _('Desktop') }}", +tablet_: "{{ _('Tablet') }}", +mobile_: "{{ _('Mobile') }}", +unknownBrowser_: "{{ _('Unknown Browser') }}", +unknownOS_: "{{ _('Unknown OS') }}", cancel_ : "{{ _('Cancel') }}", titleOrTitleArRequired_ : "{{ _('Title or Title (Ar) is required') }}", @@ -286,6 +314,37 @@ actorType_ : "{{ _('Actor Type') }}", relationshipWithBulletin_: (id) => "{{ _('Relationship with Bulletin {id}') }}".replace("{id}", id), relationshipBetweenActorAndBulletinWillBeCreated_ : "{{ _('The relationship between the new Actor and the Bulletin will be created when the Actor is saved') }}", +youAreAboutToSuspendTheAccountForUser_: (name) => "{{ _('You\'re about to suspend the account for user \'{name}\'') }}".replace('{name}', name), +byEnteringUsernameYouConfirmSuspendForUser_: (name) => "{{ _('By entering the user\'s username, you will confirm the suspend action for user \'{name}\'') }}".replace('{name}', name), +youAreAboutToReactivateUser_: (name) => "{{ _('You\'re about to reactivate user \'{name}\'') }}".replace('{name}', name), +byEnteringYourPasswordYouConfirmReactivationForUser_: (name) => "{{ _('By entering your password, you will confirm the reactivation for user \'{name}\'') }}".replace('{name}', name), +youAreAboutToDisableTheAccountForUser_: (name) => "{{ _('You\'re about to disable the account for user \'{name}\'') }}".replace('{name}', name), +byEnteringUsernameYouConfirmDisableForUser_: (name) => "{{ _('By entering the user\'s username, you will confirm the disable action for user \'{name}\'') }}".replace('{name}', name), +youAreAboutToEnableTheAccountForUser_: (name) => "{{ _('You\'re about to enable the account for user \'{name}\'') }}".replace('{name}', name), +byEnteringYourPasswordYouConfirmEnablingTheAccountForUser_: (name) => "{{ _('By entering your password, you will confirm enabling the account for user \'{name}\'') }}".replace('{name}', name), +intendedFor_: "{{ _('Intended for') }}", +rolesAndAccessRemoval_: "{{ _('Roles and Access Removal') }}", +rolesAndAccessRestoration_: "{{ _('Roles and Access Restoration') }}", +accessRemoval_: "{{ _('Access Removal') }}", +accessRestoration_: "{{ _('Access Restoration') }}", +profileAndContributions_: "{{ _('Profile and Contributions') }}", +temporarySuspensionsInternalInvestigationsOrOnLeave_: "{{ _('Temporary suspensions, such as during internal investigations or when the user is on leave.') }}", +userCanNoLongerLoginToBayanat_: "{{ _('The user will no longer be able to log in to Bayanat.') }}", +rolesAccessAndAssignmentsRemainUnchanged_: "{{ _('Their system roles, access permissions, and assigned items will remain unchanged.') }}", +permanentlyEndingUserAccessToBayanat_: "{{ _('Permanently ending a user\'s access to Bayanat, such as when they leave the organization.') }}", +userProfileRetainedForArchivalPurposes_: "{{ _('The user\'s profile will be retained for archival purposes.') }}", +itemsCreatedOrUpdatedByUserWillRemainInBayanat_: "{{ _('Items created or updated by the user will remain in Bayanat.') }}", +activityHistoryWillContinueToReflectUserContributions_: "{{ _('Activity history will continue to reflect the user\'s contributions.') }}", +previousRolesAndAccessPermissionsWillBeRestored_: "{{ _('Previous system roles and access permissions will be restored.') }}", +userCanLoginAndPerformRoleActions_: "{{ _('User can log in to Bayanat and perform actions allowed by their roles.') }}", +userCanUseExistingPasswordToAccessBayanat_: "{{ _('User can use their existing password to access Bayanat.') }}", +previousProfileAndContributionsRemainIntact_: "{{ _('The user\'s previous profile and contributions remain intact.') }}", +theUserWillBeAbleToViewAndEditItemsTheyPreviouslyHadAccessTo_: "{{ _('The user will be able to view and edit items they previously had access to.') }}", +whatYouShouldKnow_: "{{ _('What you should know') }}", +enterUsernameToConfirm_: "{{ _('Enter username to confirm') }}", +confirmWithYourPassword_: "{{ _('Confirm with your password') }}", +pleaseAssignASystemRoleToReactivateUser_: "{{ _('Please assign a system role to reactivate user') }}", + originPlace_ : "{{ _('Place of Origin') }}", idNumber_ : "{{ _('ID Number') }}", @@ -422,7 +481,6 @@ noLogs: "{{ _('No log files available') }}", URGENT_: "{{ _('URGENT') }}", noNotificationsYet_: "{{ _('No notifications yet') }}", noNotificationsYetDescription_: "{{ _("You haven't received any notifications yet. When you do, they'll appear here.") }}", -loadMore_: "{{ _('Load more') }}", noMoreNotificationsToLoad_: "{{ _('No more notifications to load.') }}", notificationsCouldNotBeLoaded_: "{{ _('Oops! Notifications couldn’t be loaded') }}", weAreHavingTroubleFetchingNotifications_: "{{ _('We’re having trouble fetching your notifications right now. Check your connection or refresh the page.') }}", @@ -468,7 +526,7 @@ loggedOut_: "{{ _('You have been logged out. Please log in again.') }}", confirmLogout_: "{{ _('Are you sure you want to log out all sessions for this user?') }}", userPermissions_: "{{ _('User Permissions') }}", userTwoFactorMethods_: "{{ _('User 2FA Methods') }}", -noTwoFactorMethods_: "{{ _('2FA Inactive') }}", +twoFaInactive_: "{{ _('2FA Inactive') }}", canViewUsernames_: "{{ _('Can View Usernames') }}", cannotViewUsernames_: "{{ _('Cannot View Usernames') }}", canViewSimpleHistory_: "{{ _('Can View Simple History') }}", @@ -483,6 +541,8 @@ canSelfAssign_: "{{ _('Can Self Assign') }}", cannotSelfAssign_: "{{ _('Cannot Self Assign') }}", userSessions_: "{{ _('User Sessions') }}", session_: "{{ _('Session') }}", +browser_: "{{ _('Browser') }}", +device_: "{{ _('Device') }}", ip_: "{{ _('IP') }}", userAgent_: "{{ _('User Agent') }}", started_: "{{ _('Started') }}", @@ -615,12 +675,15 @@ started_: "{{ _('Started') }}", logOffThisDevice_: "{{ _('Log off this device') }}", active_: "{{ _('Active') }}", inactive_: "{{ _('Inactive') }}", -logoutAllSessions_: "{{ _('Logout All Sessions') }}", +logoutAll_: "{{ _('Logout All') }}", resetPassword_: "{{ _('Reset password') }}", passwordResetAlreadyRequested_: "{{ _('Password reset already requested.') }}", -passwordResetRequested_: "{{ _('Password reset requested.') }}", +passwordResetRequested_: "{{ _('Password Reset Requested.') }}", forcePasswordReset_: "{{ _('Force password reset') }}", revoke2fa_: "{{ _('Revoke 2FA') }}", +changePassword_: "{{ _('Change Password') }}", +changePasswordForUser_: (name) => "{{ _('Change Password for user “{name}”') }}".replace("{name}", name), +youreAboutToForceAPasswordResetForUser_: (name) => "{{ _('You’re about to force a password reset for user “{name}”') }}".replace("{name}", name), // validations mustBeMaxCharactersOrFewer_: (max) => "{{ _('Must be {max} characters or fewer.') }}".replace("{max}", max), diff --git a/enferno/admin/templates/admin/labels.html b/enferno/admin/templates/admin/labels.html index 3ecf9f0b8..9314982da 100644 --- a/enferno/admin/templates/admin/labels.html +++ b/enferno/admin/templates/admin/labels.html @@ -279,7 +279,7 @@ {title: "{{_('Actor')}}", value: "for_actor"}, {title: "{{_('Incident')}}", value: "for_incident"}, {title: "{{_('Offline')}}", value: "for_offline"}, - {% if current_user.roles_in(['Admin','Mod']) %} + {% if current_user.roles_in(['Admin','Moderator']) %} {title: "{{_('Actions')}}", value: "action", sortable: false} {% endif %} ], diff --git a/enferno/admin/templates/admin/locations.html b/enferno/admin/templates/admin/locations.html index 18f4e62b2..f095a432c 100644 --- a/enferno/admin/templates/admin/locations.html +++ b/enferno/admin/templates/admin/locations.html @@ -210,7 +210,7 @@ {title: "{{_('Location Type')}}", value: "location_type.title"}, {title: "{{_('Admin Level')}}", value: "admin_level.title"}, - {% if current_user.roles_in(['Admin','Mod']) or current_user.can_edit_locations %} + {% if current_user.roles_in(['Admin','Moderator']) or current_user.can_edit_locations %} {title: "{{_('Actions')}}", value: "action", sortable: false} {% endif %} ], @@ -375,7 +375,7 @@ editAllowed() { if (this.has_role(this.currentUser, 'Admin')) { return true; - } else if (this.has_role(this.currentUser, 'Mod')) { + } else if (this.has_role(this.currentUser, 'Moderator')) { return true; } else if (this.currentUser.can_edit_locations) { return true @@ -466,7 +466,7 @@ }, confirmClose() { - if (confirm(translations.confirm_)) { + if (confirm(translations.areYouSure_)) { this.dialog = false; setTimeout(() => { this.editedItem = Object.assign({}, this.defaultItem); diff --git a/enferno/admin/templates/admin/partials/bulk_actor_drawer.html b/enferno/admin/templates/admin/partials/bulk_actor_drawer.html index 5a9b439c0..b73946154 100644 --- a/enferno/admin/templates/admin/partials/bulk_actor_drawer.html +++ b/enferno/admin/templates/admin/partials/bulk_actor_drawer.html @@ -22,7 +22,7 @@
- \ No newline at end of file diff --git a/enferno/admin/templates/admin/roles.html b/enferno/admin/templates/admin/roles.html index ac2742f34..85e238b4f 100644 --- a/enferno/admin/templates/admin/roles.html +++ b/enferno/admin/templates/admin/roles.html @@ -170,7 +170,7 @@