Skip to content
Merged

Develop #4065

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c695cf1
Add vendor number on profile
smihalache06 Sep 19, 2025
d847b72
Merge pull request #4051 from unicef/276645_lmsm_add_vendor_number_on…
fpbonet Sep 19, 2025
f453888
Prevent deactivation of activities with DCT (#4049)
alex-run-code Sep 19, 2025
1f07142
[LMSM] Add soft delete items on stock management (#4050)
smihalache06 Sep 22, 2025
98a3ae3
added view only permission for vision logs (#4053)
alex-run-code Sep 22, 2025
3a27434
hotfix : allow location/id/path to be fetched even if location inacti…
alex-run-code Sep 22, 2025
602a305
[257186] Remove inactive users from dropdown if not already selected …
merceaemil Sep 22, 2025
fe73a2f
fix automatic pd status transition
emaciupe Sep 23, 2025
cfd592a
PRP pd list: order by id descending
emaciupe Sep 23, 2025
cbb0544
Merge branch 'develop' into 274658-reporting-periods-changes
emaciupe Sep 23, 2025
fc5f240
Merge pull request #4055 from unicef/274658-reporting-periods-changes
emaciupe Sep 24, 2025
a7a0834
Special_reports not editable in amendment as changes are discarded
emaciupe Sep 24, 2025
e5723e5
Merge pull request #4056 from unicef/274658-reporting-periods-changes
emaciupe Sep 24, 2025
f1a0dee
fix special_reporting_requirements edit permission on endpoint
emaciupe Sep 25, 2025
d4eeaa2
fix tests
emaciupe Sep 25, 2025
f937c2f
Merge pull request #4058 from unicef/274658-reporting-periods-changes
emaciupe Sep 25, 2025
076e469
270094 add both options (#4054)
alex-run-code Sep 26, 2025
762eb1b
Add CAN as UOM for material (#4057)
smihalache06 Sep 26, 2025
a367b8c
Remove old stuff + close security report (#4060)
fpbonet Sep 26, 2025
2026ed3
[LMSM] Add item audit log (#4052)
smihalache06 Sep 29, 2025
e36aca1
created new app, added endpoints and tests (#4061)
alex-run-code Sep 29, 2025
7a00f29
PRP Intervention list serializer: remove active pd logic
emaciupe Sep 29, 2025
0253f18
Merge branch 'develop' of github.com:unicef/etools into 274658-report…
emaciupe Sep 29, 2025
97ae979
Merge pull request #4064 from unicef/274658-reporting-periods-changes
emaciupe Sep 30, 2025
8f1da8f
removed validation of tpm partner when both is selected
alex-run-code Sep 30, 2025
e5c48b9
278284 agreements endpoints (#4062)
alex-run-code Sep 30, 2025
c27310b
Merge branch 'develop' into 270094_deactivate_tpm_validator_for_both
emaciupe Oct 1, 2025
62de504
Merge pull request #4067 from unicef/270094_deactivate_tpm_validator_…
emaciupe Oct 1, 2025
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
Binary file removed screenshots/PCA_process.jpg
Binary file not shown.
Binary file removed screenshots/equitrack_map.png
Binary file not shown.
Binary file removed screenshots/equitrack_pcas.png
Binary file not shown.
19 changes: 0 additions & 19 deletions src/etools/applications/core/data/users.json

This file was deleted.

22 changes: 20 additions & 2 deletions src/etools/applications/core/middleware.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import threading

from django.conf import settings
from django.contrib.contenttypes.models import ContentType
Expand Down Expand Up @@ -28,6 +29,8 @@

INACTIVE_WORKSPACE_URL = reverse('workspace-inactive')

_thread_locals = threading.local()


class QueryCountDebugMiddleware(MiddlewareMixin):
""" Debug db connections"""
Expand Down Expand Up @@ -153,13 +156,28 @@ def __init__(self, get_response):

def __call__(self, request):
# Check if the request method is not GET
if request.user.is_authenticated:
_thread_locals.user = request.user
if request.user.is_authenticated and request.user.is_unicef_user():
return self.get_response(request)
try:
response = self.get_response(request)
return response
finally:
_thread_locals.user = None

if request.user.is_authenticated:
# check where they're trying to access:
if any(request.path.startswith(path) for path in settings.PARTNER_PROTECTED_URLS):
user_group_names = [g.name for g in request.user.groups]
if not any([g in PARTNER_PD_ACTIVE_GROUPS for g in user_group_names]):
return HttpResponseForbidden("You don't have permission to perform this action.")
return self.get_response(request)
try:
response = self.get_response(request)
return response
finally:
_thread_locals.user = None


def get_current_user():
user = getattr(_thread_locals, 'user', None)
return user if user else None
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ def test_get_path(self):
self.assertEqual(response.data[0]['id'], str(self.country.id))
self.assertEqual(response.data[1]['id'], str(self.child_location.id))

def test_get_path_inactive_location(self):
"""Path endpoint should be accessible for inactive locations and return only active ancestors."""
inactive_child = LocationFactory(parent=self.country, is_active=False)

response = self.forced_auth_req(
'get', reverse('field_monitoring_settings:locations-path', args=[inactive_child.id]),
user=self.unicef_user,
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
# Inactive location itself is excluded; only active ancestors should be returned
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['id'], str(self.country.id))


class LocationSitesViewTestCase(TestExportMixin, FMBaseTestCaseMixin, BaseTenantTestCase):
def setUp(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ class FMLocationsViewSet(FMBaseViewSet, mixins.ListModelMixin, viewsets.GenericV

@action(methods=['get'], detail=True)
def path(self, request, *args, **kwargs):
ancestors = self.get_object().get_ancestors(include_self=True).filter(is_active=True)
location = get_object_or_404(Location, pk=kwargs.get('pk'))
ancestors = location.get_ancestors(include_self=True).filter(is_active=True)
return Response(data=self.get_serializer(instance=ancestors, many=True).data)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,19 @@ def is_ma_user():
return is_visit_lead() or is_team_member()

monitor_types = self.instance.MONITOR_TYPE_CHOICES
is_staff_visit = self.instance.monitor_type in [monitor_types.staff, monitor_types.both]
is_tpm_visit = self.instance.monitor_type in [monitor_types.tpm, monitor_types.both]
visit_lead = is_visit_lead()
ma_user = is_ma_user()

self.condition_map = {
'is_ma_related_user': is_ma_user(),
'is_visit_lead': is_visit_lead(),
'tpm_visit': self.instance.monitor_type == monitor_types.tpm,
'staff_visit': self.instance.monitor_type == monitor_types.staff,
'staff_visit+is_visit_lead': self.instance.monitor_type == monitor_types.staff and is_visit_lead(),
'tpm_visit+tpm_ma_related': self.instance.monitor_type == monitor_types.tpm and is_ma_user(),
'tpm_visit+tpm_ma_related+is_visit_lead': (self.instance.monitor_type == monitor_types.tpm and is_ma_user()) or is_visit_lead(),
'is_ma_related_user': ma_user,
'is_visit_lead': visit_lead,
'tpm_visit': is_tpm_visit,
'staff_visit': is_staff_visit,
'staff_visit+is_visit_lead': is_staff_visit and visit_lead,
'tpm_visit+tpm_ma_related': is_tpm_visit and ma_user,
'tpm_visit+tpm_ma_related+is_visit_lead': (is_tpm_visit and ma_user) or visit_lead,
}

if getattr(self.instance, 'old', None) is not None:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from django.db import connection
from django.utils.translation import gettext as _

from etools_validator.exceptions import BasicValidationError

from etools.applications.field_monitoring.planning.models import MonitoringActivity
from etools.applications.partners.models import PartnerOrganization
from etools.applications.reports.models import CountryProgramme, Result
from etools.applications.users.models import Realm


def staff_activity_has_no_tpm_partner(i):
Expand All @@ -14,10 +16,12 @@ def staff_activity_has_no_tpm_partner(i):


def tpm_staff_members_belongs_to_the_partner(i):
if not i.tpm_partner:
# For BOTH we do not restrict TPM team members to the selected TPM partner
if not i.tpm_partner or i.monitor_type == MonitoringActivity.MONITOR_TYPE_CHOICES.both:
return True

team_members = set([tm.id for tm in i.team_members.all()])
team_members_qs = list(i.team_members.all())
team_members = set([tm.id for tm in team_members_qs])
if i.old_instance:
old_team_members = set([tm.id for tm in i.old_instance.team_members.all()])
members_to_validate = team_members - old_team_members
Expand Down Expand Up @@ -63,3 +67,22 @@ def interventions_connected_with_cp_outputs(i):
raise BasicValidationError(error_text % ', '.join(wrong_cp_outputs))

return True


def assignees_have_active_access(i):
if i.monitor_type != MonitoringActivity.MONITOR_TYPE_CHOICES.both:
return True

def has_active_realm(user):
return Realm.objects.filter(country=connection.tenant, user=user, is_active=True).exists()

# visit lead
if i.visit_lead and not has_active_realm(i.visit_lead):
raise BasicValidationError(_('Visit lead is inactive or has no access'))

# team members
inactive_members = [tm.get_full_name() for tm in i.team_members.all() if not has_active_realm(tm)]
if inactive_members:
raise BasicValidationError(_('Team members inactive or with no access: %s') % ', '.join(inactive_members))

return True
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@


def tpm_partner_is_assigned_for_tpm_activity(i):
if i.monitor_type == MonitoringActivity.MONITOR_TYPE_CHOICES.tpm and not i.tpm_partner:
if i.monitor_type in [MonitoringActivity.MONITOR_TYPE_CHOICES.tpm, MonitoringActivity.MONITOR_TYPE_CHOICES.both] \
and not i.tpm_partner:
raise StateValidationError([_('Partner is not defined for TPM activity')])
return True

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from etools.applications.field_monitoring.planning.activity_validation.permissions import ActivityPermissions
from etools.applications.field_monitoring.planning.activity_validation.validations.basic import (
assignees_have_active_access,
interventions_connected_with_partners,
staff_activity_has_no_tpm_partner,
tpm_staff_members_belongs_to_the_partner,
Expand All @@ -28,6 +29,7 @@ class ActivityValid(CompleteValidation):
staff_activity_has_no_tpm_partner,
tpm_staff_members_belongs_to_the_partner,
interventions_connected_with_partners,
assignees_have_active_access,
# interventions_connected_with_cp_outputs,
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ msgstr "لقد حددت PD/SPD وألغيت تحديد بعض الشركاء ا
msgid "You've selected a PD/SPD and unselected some of it's corresponding outputs, please either remove the PD or add the outputs back before saving: %s"
msgstr "لقد حددت PD/SPD وألغيت تحديد بعض المخرجات المقابلة لها، يرجى إما إزالة PD أو إضافة المخرجات مرة أخرى قبل الحفظ: %s"

msgid "Visit lead is inactive or has no access"
msgstr "مسؤول الزيارة غير نشط أو ليس لديه حق الوصول"

#, python-format
msgid "Team members inactive or with no access: %s"
msgstr "أعضاء الفريق غير نشطين أو بدون حق وصول: %s"

msgid "Partner is not defined for TPM activity"
msgstr "لم يتم تحديد الشريك لنشاط المُراقب المستقل"

Expand Down Expand Up @@ -166,6 +173,9 @@ msgstr "الموظفين"
msgid "TPM"
msgstr "المُراقب المستقل"

msgid "Both"
msgstr "كلاهما"

msgid "Draft"
msgstr "الصياغة التمهيدية"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ msgstr "Has seleccionado un PD/SPD y has desmarcado algunos de sus socios corres
msgid "You've selected a PD/SPD and unselected some of it's corresponding outputs, please either remove the PD or add the outputs back before saving: %s"
msgstr "Has seleccionado un PD/SPD y has desmarcado algunas de los productos del CP correspondientes. Elimine el PD o vuelva a añadir los productos del CP antes de guardar: %s"

msgid "Visit lead is inactive or has no access"
msgstr "El responsable de la visita está inactivo o no tiene acceso"

#, python-format
msgid "Team members inactive or with no access: %s"
msgstr "Miembros del equipo inactivos o sin acceso: %s"

msgid "Partner is not defined for TPM activity"
msgstr "Socio no está definido para actividad de TPM"

Expand Down Expand Up @@ -166,6 +173,9 @@ msgstr "Personal"
msgid "TPM"
msgstr "TPM"

msgid "Both"
msgstr "Ambos"

msgid "Draft"
msgstr "Borrador"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ msgstr "Vous avez sélectionné un PD/SSFA et désélectionné certains de ses p
msgid "You've selected a PD/SPD and unselected some of it's corresponding outputs, please either remove the PD or add the outputs back before saving: %s"
msgstr "Vous avez sélectionné un PD/SSFA et désélectionné certaines de ses produits correspondantes, veuillez supprimer le PD ou rajouter les produits avant d'enregistrer: %s"

msgid "Visit lead is inactive or has no access"
msgstr "Le responsable de la visite est inactif ou n'a pas d'accès"

#, python-format
msgid "Team members inactive or with no access: %s"
msgstr "Membres de l'équipe inactifs ou sans accès : %s"

msgid "Partner is not defined for TPM activity"
msgstr "Le partenaire n'est pas défini pour l'activité TPM"

Expand Down Expand Up @@ -166,6 +173,9 @@ msgstr "Personnel"
msgid "TPM"
msgstr "TPM"

msgid "Both"
msgstr "Les deux"

msgid "Draft"
msgstr "Draft"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ msgstr "Você selecionou um PD/SPD e desmarcou alguns de seus parceiros correspo
msgid "You've selected a PD/SPD and unselected some of it's corresponding outputs, please either remove the PD or add the outputs back before saving: %s"
msgstr "Você selecionou um PD/SPD e desmarcou algumas de suas saídas correspondentes, remova o PD ou adicione as saídas antes de salvar: %s"

msgid "Visit lead is inactive or has no access"
msgstr "O responsável pela visita está inativo ou sem acesso"

#, python-format
msgid "Team members inactive or with no access: %s"
msgstr "Membros da equipe inativos ou sem acesso: %s"

msgid "Partner is not defined for TPM activity"
msgstr "Parceiro não está definido para atividade TPM"

Expand Down Expand Up @@ -166,6 +173,9 @@ msgstr "Pessoal"
msgid "TPM"
msgstr ""

msgid "Both"
msgstr "Ambos"

msgid "Draft"
msgstr ""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ msgstr "Вы выбрали PD/SPD и отменили выбор некотор
msgid "You've selected a PD/SPD and unselected some of it's corresponding outputs, please either remove the PD or add the outputs back before saving: %s"
msgstr "Вы выбрали PD/SPD и сняли выделение с некоторых соответствующих выходов, пожалуйста, удалите PD или добавьте выходы обратно перед сохранением: %s"

msgid "Visit lead is inactive or has no access"
msgstr "Руководитель визита неактивен или не имеет доступа"

#, python-format
msgid "Team members inactive or with no access: %s"
msgstr "Члены команды неактивны или без доступа: %s"

msgid "Partner is not defined for TPM activity"
msgstr "Партнер не определен для активности TPM."

Expand Down Expand Up @@ -166,6 +173,9 @@ msgstr "Персонал"
msgid "TPM"
msgstr "ТПМ"

msgid "Both"
msgstr "Оба"

msgid "Draft"
msgstr ""

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.23 on 2025-09-24 07:09

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('field_monitoring_planning', '0008_facilitytype_monitoringactivity_facility_type'),
]

operations = [
migrations.AlterField(
model_name='monitoringactivity',
name='monitor_type',
field=models.CharField(choices=[('staff', 'Staff'), ('tpm', 'TPM'), ('both', 'Both')], default='staff', max_length=10),
),
]
28 changes: 22 additions & 6 deletions src/etools/applications/field_monitoring/planning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ class MonitoringActivity(
MONITOR_TYPE_CHOICES = Choices(
('staff', _('Staff')),
('tpm', _('TPM')),
('both', _('Both')),
)

STATUS_DRAFT = 'draft'
Expand Down Expand Up @@ -568,23 +569,38 @@ def auto_accept_staff_activity(self, old_instance):
recipients = set(
list(self.team_members.all()) + [self.visit_lead]
)
# decide templates, then send
deliveries = []
# check if it was rejected otherwise send assign message
if old_instance and old_instance.status == self.STATUSES.submitted:
email_template = "fm/activity/staff-reject"
recipients = [self.visit_lead]
deliveries = [
(self.visit_lead, "fm/activity/staff-reject"),
]
elif self.monitor_type == self.MONITOR_TYPE_CHOICES.staff:
email_template = 'fm/activity/staff-assign'
deliveries = [(r, 'fm/activity/staff-assign') for r in recipients]
elif self.monitor_type == self.MONITOR_TYPE_CHOICES.tpm:
deliveries = [(r, 'fm/activity/assign') for r in recipients]
elif self.monitor_type == self.MONITOR_TYPE_CHOICES.both:
# send staff template to UNICEF Users and TPM template to TPM users
deliveries = [
(
r,
'fm/activity/staff-assign' if r.groups.filter(name='UNICEF User').exists() else 'fm/activity/assign'
)
for r in recipients
]
else:
email_template = 'fm/activity/assign'
for recipient in recipients:
deliveries = [(r, 'fm/activity/assign') for r in recipients]

for recipient, email_template in deliveries:
self._send_email(
recipient.email,
email_template,
context={'recipient': recipient.get_full_name()},
user=recipient
)

if self.monitor_type == self.MONITOR_TYPE_CHOICES.staff:
if self.monitor_type in [self.MONITOR_TYPE_CHOICES.staff, self.MONITOR_TYPE_CHOICES.both]:
self.accept()
self.save()
# todo: direct transitions doesn't trigger side effects.
Expand Down
Loading
Loading