Source code for akvo.rsr.models.organisation

# -*- coding: utf-8 -*-

# Akvo RSR is covered by the GNU Affero General Public License.
# See more details in the license.txt file located at the root folder of the Akvo RSR module.
# For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum, Q, signals
from django.dispatch import receiver
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django_q.tasks import async_task

from sorl.thumbnail.fields import ImageField
from akvo.password_policy.models import PolicyConfig

from akvo.utils import codelist_choices, codelist_name, rsr_image_path
from akvo.rsr.usecases.toggle_org_enforce_2fa import toggle_enfore_2fa

from ..mixins import TimestampsMixin
from ..fields import ValidXMLCharField, ValidXMLTextField
from akvo.codelists.store.default_codelists import CURRENCY, ORGANISATION_TYPE
from akvo.codelists.models import Currency

from .country import Country
from .model_querysets.organisation import OrgManager
from .partner_site import PartnerSite
from .partnership import Partnership
from .publishing_status import PublishingStatus
from .project_update import ProjectUpdate

ORG_TYPE_NGO = 'N'
ORG_TYPE_GOV = 'G'
ORG_TYPE_COM = 'C'
ORG_TYPE_KNO = 'K'
ORG_TYPES = (
    (ORG_TYPE_NGO, _('NGO')),
    (ORG_TYPE_GOV, _('Governmental')),
    (ORG_TYPE_COM, _('Commercial')),
    (ORG_TYPE_KNO, _('Knowledge institution')),
)


[docs]def image_path(instance, file_name): return rsr_image_path(instance, file_name, 'db/org/%(instance_pk)s/%(file_name)s')
[docs]class Organisation(TimestampsMixin): """ There are four types of organisations in RSR, called Field, Support, Funding and Sponsor partner respectively. """ NEW_TO_OLD_TYPES = [ ORG_TYPE_GOV, ORG_TYPE_GOV, ORG_TYPE_NGO, ORG_TYPE_NGO, ORG_TYPE_NGO, ORG_TYPE_NGO, ORG_TYPE_NGO, ORG_TYPE_NGO, ORG_TYPE_COM, ORG_TYPE_KNO ]
[docs] @classmethod def org_type_from_iati_type(cls, iati_type): """ utility that maps the IATI organisation types to the old Akvo organisation types """ types = dict(zip([int(type) for type, name in ORGANISATION_TYPE[1:]], cls.NEW_TO_OLD_TYPES )) return types[iati_type]
name = ValidXMLCharField( _('name'), max_length=40, db_index=True, unique=True, help_text=_('Short name which will appear in organisation and partner listings ' '(25 characters).') ) long_name = ValidXMLCharField( _('long name'), max_length=100, db_index=True, unique=True, help_text=_('Full name of organisation (75 characters).'), ) language = ValidXMLCharField( _('language'), max_length=2, choices=settings.RSR_LANGUAGES, default='en', help_text=_('The main language of the organisation'), ) organisation_type = ValidXMLCharField( _('organisation type'), max_length=1, db_index=True, choices=ORG_TYPES, blank=True, null=True ) currency = ValidXMLCharField( _('currency'), choices=codelist_choices(CURRENCY), max_length=3, default='EUR', help_text=_('The default currency for this organisation. Used in all financial ' 'aspects of the organisation.') ) new_organisation_type = models.IntegerField( _('IATI organisation type'), db_index=True, choices=[(int(c[0]), c[1]) for c in codelist_choices(ORGANISATION_TYPE)], default=22, help_text=_('Check that this field is set to an organisation type that ' 'matches your organisation.'), ) iati_org_id = ValidXMLCharField( _('IATI organisation ID'), max_length=75, blank=True, null=True, db_index=True, unique=True, default=None ) internal_org_ids = models.ManyToManyField( 'self', through='InternalOrganisationID', symmetrical=False, related_name='recording_organisation' ) logo = ImageField(_('logo'), blank=True, upload_to=image_path, help_text=_('Logos should be approximately 360x270 pixels ' '(approx. 100-200kB in size) on a white background.') ) url = models.URLField( blank=True, help_text=_('Enter the full address of your web site, beginning with http://.'), ) facebook = models.URLField( blank=True, help_text=_('Enter the full address of your Facebook page, beginning with http://.'), ) twitter = models.URLField( blank=True, help_text=_('Enter the full address of your Twitter feed, beginning with http://.'), ) linkedin = models.URLField( blank=True, help_text=_('Enter the full address of your LinkedIn page, beginning with http://.'), ) phone = ValidXMLCharField( _('phone'), blank=True, max_length=20, help_text=_('(20 characters).') ) mobile = ValidXMLCharField( _('mobile'), blank=True, max_length=20, help_text=_('(20 characters).') ) fax = ValidXMLCharField( _('fax'), blank=True, max_length=20, help_text=_('(20 characters).') ) contact_person = ValidXMLCharField( _('contact person'), blank=True, max_length=30, help_text=_('Name of external contact person for your organisation (30 characters).'), ) contact_email = ValidXMLCharField( _('contact email'), blank=True, max_length=50, help_text=_('Email to which inquiries about your organisation should be sent ' '(50 characters).'), ) description = ValidXMLTextField( _('description'), blank=True, help_text=_('Describe your organisation.') ) notes = ValidXMLTextField(verbose_name=_("Notes and comments"), blank=True, default='') primary_location = models.ForeignKey( 'OrganisationLocation', null=True, on_delete=models.SET_NULL ) can_create_projects = models.BooleanField( default=False, help_text=_('Partner editors of this organisation can create new projects, and publish ' 'projects it is a partner of.') ) enforce_program_projects = models.BooleanField( default=False, help_text=_('Enforce creation of projects only under programs. If the organisation has ' 'explicitly set a content owner, this flag remains in sync with the content owner')) use_project_roles = models.BooleanField( verbose_name=_(u"use project roles"), default=False, help_text=_(u'Projects with this organisation as reporting partner will use project' u'roles for permissions instead of employment based permissions')) content_owner = models.ForeignKey( 'self', null=True, blank=True, on_delete=models.SET_NULL, help_text=_('Organisation that maintains content for this organisation through the API.') ) original = models.OneToOneField('self', related_name='shadow', null=True, blank=True, on_delete=models.SET_NULL, help_text='Pointer to original organisation if this is a ' 'shadow. Used by EUTF') public_iati_file = models.BooleanField( _('Show latest exported IATI file on organisation page.'), default=True ) enforce_2fa = models.BooleanField( _('Enforce 2-Factor-Authentication'), default=False, help_text=_('Enfore related users (through employment or project access) to enable their 2FA'), ) password_policy = models.ForeignKey( PolicyConfig, null=True, blank=True, on_delete=models.SET_NULL, help_text='Password policies config for employees of this organization.' ) # TODO: Should be removed can_become_reporting = models.BooleanField( _('Reportable'), help_text=_('Organisation is allowed to become a reporting organisation. ' 'Can be set by superusers.'), default=False) iati_prefixes = ValidXMLCharField( _('IATI identifier prefixes'), max_length=2000, blank=True, null=True, help_text=_('This is a ; separated list of IATI identifier prefixes used by projects' 'where the organisation is a reporting partner.') ) codelist = models.ForeignKey( 'OrganisationCodelist', null=True, blank=True, on_delete=models.SET_NULL ) objects = OrgManager()
[docs] def get_absolute_url(self): return reverse('organisation-main', kwargs={'organisation_id': self.pk})
@cached_property def cacheable_url(self): return self.get_absolute_url() @property def canonical_name(self): return self.long_name or self.name __original_enforce_2fa = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__original_enforce_2fa = self.enforce_2fa
[docs] def save(self, *args, **kwargs): if self.enforce_2fa != self.__original_enforce_2fa: async_task(toggle_enfore_2fa, self) super().save(*args, **kwargs) self.__original_enforce_2fa = self.enforce_2fa
[docs] def clean(self): """Organisations can only be saved when we're sure that they do not exist already.""" validation_errors = {} name = self.name.strip() long_name = self.long_name.strip() iati_org_id = self.iati_org_id.strip() if self.iati_org_id else None names = Organisation.objects.filter(name__iexact=name) long_names = Organisation.objects.filter(long_name__iexact=long_name) ids = Organisation.objects.filter(iati_org_id__iexact=iati_org_id) if self.pk: names = names.exclude(pk=self.pk) long_names = long_names.exclude(pk=self.pk) ids = ids.exclude(pk=self.pk) if name and names.exists(): validation_errors['name'] = '{}: {}'.format( _('An Organisation with this name already exists'), name) elif not name: # This prevents organisation names with only spaces validation_errors['name'] = _('Organisation name may not be blank') if long_name and long_names.exists(): validation_errors['long_name'] = '{}: {}'.format( _('An Organisation with this long name already exists'), long_name) elif not long_name: # This prevents organisation long names with only spaces validation_errors['long_name'] = _('Organisation long name may not be blank') if iati_org_id and ids: validation_errors['iati_org_id'] = '{}: {}'.format( _('An Organisation with this IATI organisation identifier already exists'), ids[0].name) if validation_errors: raise ValidationError(validation_errors)
def __str__(self): return self.name
[docs] def iati_org_type(self): return dict(ORGANISATION_TYPE)[str(self.new_organisation_type)] if \ self.new_organisation_type else ""
[docs] def iati_org_type_unicode(self): return str(self.iati_org_type())
[docs] def partnersites(self): "returns the partnersites belonging to the organisation in a PartnerSite queryset" return PartnerSite.objects.filter(organisation=self)
[docs] def website(self): return '<a href="%s">%s</a>' % (self.url, self.url,)
website.allow_tags = True
[docs] def all_users(self): "returns a queryset of all users belonging to the organisation" return self.users.all()
[docs] def published_projects(self, only_public=True): "returns a queryset with published projects that has self as any kind of partner" projects = self.projects.published().distinct() return projects.public() if only_public else projects
[docs] def all_projects(self): """returns a queryset with all projects that has self as any kind of partner.""" return self.projects.distinct()
[docs] @staticmethod def all_updates_filter(org_id): """Returns a Q object for filtering updates for an organisation.""" return Q(user__organisations__id=org_id, project__partners__id=org_id)
[docs] def all_updates(self): """Returns a queryset with all updates of the organisation. Updates of the organisation are updates which have been: 1. Posted by users employed by the organisation AND 2. Posted on projects where the organisation is a partner. """ return ProjectUpdate.objects.filter(Organisation.all_updates_filter(self.id)).distinct()
[docs] def public_updates(self): """Returns a queryset with public updates of the organisation.""" all_updates = self.all_updates() return all_updates.filter(project__in=self.published_projects()).distinct()
[docs] def reporting_on_projects(self): """returns a queryset with all projects that has self as reporting organisation.""" return self.projects.filter( partnerships__organisation=self, partnerships__iati_organisation_role=Partnership.IATI_REPORTING_ORGANISATION )
[docs] def active_projects(self): return self.published_projects().status_not_cancelled().status_not_archived()
[docs] def partners(self): """ Returns a queryset of all organisations that self has at least one project in common with, excluding self. """ return self.all_projects().all_partners().exclude(id__exact=self.id)
[docs] def support_partners(self): """ Returns a queryset of support partners that self has at least one project in common with, excluding self. """ return self.all_projects().support_partners().exclude(id__exact=self.id)
[docs] def field_partners(self): """ Returns an Organisation queryset of field partners of which self has at least one project in common with. """ return self.all_projects().field_partners().exclude(id__exact=self.id)
[docs] def has_partner_types(self, project): """Return a list of partner types of this organisation to the project""" return [ dict(Partnership.IATI_ROLES)[role] for role in Partnership.objects.filter( project=project, organisation=self, iati_organisation_role__isnull=False ).values_list('iati_organisation_role', flat=True) ]
[docs] def content_owned_organisations(self, exclude_orgs=None): """ Returns a list of Organisations of which this organisation is the content owner. Includes self and is recursive. """ org = Organisation.objects.get(pk=self.pk) queryset = Organisation.objects.filter(pk=org.pk) # If the organisation is a paying partner, add all implementing # partners to the queryset if org.can_create_projects: field_partners = org.all_projects().field_partners().exclude(can_create_projects=True) queryset = Organisation.objects.filter( Q(pk=org.id) | Q(pk__in=field_partners.values_list('pk', flat=True)) ) kids = Organisation.objects.filter(content_owner_id=org.id).exclude(id=org.id) if exclude_orgs is not None: kids = kids.exclude(pk__in=exclude_orgs) if kids.exists(): exclude_orgs = Organisation.objects.filter(Q(pk=self.pk) | Q(pk__in=kids)) grand_kids = kids.content_owned_organisations(exclude_orgs=exclude_orgs) kids_content_owned_orgs = Organisation.objects.filter( Q(pk__in=queryset.values_list('pk', flat=True)) | Q(pk__in=kids.values_list('pk', flat=True)) | Q(pk__in=grand_kids.values_list('pk', flat=True)) ).distinct() return kids_content_owned_orgs return queryset
[docs] def content_owned_by(self): """ Returns a list of Organisations of which this organisation is content owned by. Basically the reverse of content_owned_organisations(). Includes self. """ # Always select the organisation itself and the organisation that is specifically set as # content owner of the organisation. if self.content_owner: queryset = Organisation.objects.filter(pk__in=[self.pk, self.content_owner.pk]) else: queryset = Organisation.objects.filter(pk=self.pk) # In case this partner is not a paying partner, find all projects where this partner is # implementing partner and add the paying partners of those projects to the queryset. if not self.can_create_projects: from akvo.rsr.models import Project implementing_partnerships = Partnership.objects.filter( iati_organisation_role=Partnership.IATI_IMPLEMENTING_PARTNER, organisation=self) implementing_ids = implementing_partnerships.values_list('project', flat=True) projects = Project.objects.filter(id__in=implementing_ids) paying_partners = projects.paying_partners() queryset = Organisation.objects.filter( Q(pk__in=queryset.values_list('pk', flat=True)) | Q(pk__in=paying_partners.values_list('pk', flat=True)) ) return queryset.distinct()
[docs] def get_original(self): "Returns the original org if self is a shadow org" return self.original if self.original else self
[docs] def countries_where_active(self): """Returns a Country queryset of countries where this organisation has published projects.""" return Country.objects.filter( projectlocation__project__partnerships__organisation=self, projectlocation__project__publishingstatus__status=PublishingStatus.STATUS_PUBLISHED ).distinct()
[docs] def organisation_countries(self): """Returns a list of the organisations countries.""" countries = [] for location in self.locations.all(): if location.iati_country: countries.append(location.iati_country_value().name) return countries
[docs] def iati_file(self): """ Looks up the latest public IATI file of this organisation. :return: String of IATI file or None """ for export in self.iati_exports.filter(latest=True, status=3).order_by('-last_modified_at'): if export.iati_file: return export.iati_file return None
[docs] def iati_file_unicode(self): return str(self.iati_file())
# New API
[docs] def has_multiple_project_currencies(self): "Check if organisation has projects with different currencies" if self.active_projects().distinct().count() == self.org_currency_projects_count(): return False else: return True
[docs] def currency_label(self): return codelist_name(Currency, self, 'currency')
[docs] def amount_pledged(self): "How much the organisation has pledged to projects in default currency" return self.active_projects().filter(currency=self.currency).filter( partnerships__organisation__exact=self, partnerships__iati_organisation_role__exact=Partnership.IATI_FUNDING_PARTNER ).aggregate( amount_pledged=Sum('partnerships__funding_amount') )['amount_pledged'] or 0
[docs] def org_currency_projects_count(self): "How many projects with budget in default currency the organisation is a partner to" return self.active_projects().filter(currency=self.currency).distinct().count()
def _aggregate_funds_needed(self, projects): return sum(projects.values_list('funds_needed', flat=True))
[docs] def org_currency_funds_needed(self): """How much is still needed to fully fund all projects with default currency budget that the organisation is a partner to. The ORM aggregate() doesn't work here since we may have multiple partnership relations to the same project.""" return self._aggregate_funds_needed(self.active_projects().filter(currency=self.currency).distinct())
class Meta: app_label = 'rsr' verbose_name = _('organisation') verbose_name_plural = _('organisations') permissions = ( ('user_management', 'Can manage users'), )
[docs]@receiver(signals.post_save, sender=Organisation) def enforce_projects_creation_under_programs(sender, **kwargs): """Keep the flag enforce_program_projects in sync for content owned orgs. *NOTE*: We only change the flag for organisations that directly set this organisation as a content owner explicitly. Implicit content owned organisations are not touched. """ # Disable signal handler when loading fixtures if kwargs.get('raw', False): return org = kwargs['instance'] Organisation.objects.filter(content_owner=org).update( enforce_program_projects=org.enforce_program_projects) # Organisation with content_owner is always in sync with the content_owner, # if they have one set explicitly. if org.content_owner and org.enforce_program_projects != org.content_owner.enforce_program_projects: org.enforce_program_projects = org.content_owner.enforce_program_projects org.save(update_fields=['enforce_program_projects'])