From d846bc055aa0baa927a9b26961814fa4fa9bb0b7 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 10 May 2024 16:32:47 -0400 Subject: [PATCH 1/3] Implemented Organizations inheriting from the Associate class in models.py --- src/_main_/settings.py | 2 +- src/_main_/utils/footage/FootageConstants.py | 1 + src/api/urls.py | 4 + src/api/utils/filter_functions.py | 28 ++++ src/database/models.py | 165 +++++++++++++++---- 5 files changed, 169 insertions(+), 31 deletions(-) diff --git a/src/_main_/settings.py b/src/_main_/settings.py index b02128712..d82f0b6ce 100644 --- a/src/_main_/settings.py +++ b/src/_main_/settings.py @@ -32,7 +32,7 @@ # Database selection, development DB unless one of these chosen IS_PROD = False IS_CANARY = False -IS_LOCAL = False +IS_LOCAL = True RUN_SERVER_LOCALLY = IS_LOCAL RUN_CELERY_LOCALLY = IS_LOCAL diff --git a/src/_main_/utils/footage/FootageConstants.py b/src/_main_/utils/footage/FootageConstants.py index 78c397776..a9f05ed72 100644 --- a/src/_main_/utils/footage/FootageConstants.py +++ b/src/_main_/utils/footage/FootageConstants.py @@ -89,6 +89,7 @@ "ACTION": {"key": "ACTION", "json_field": "is_action"}, "EVENT": {"key": "EVENT", "json_field": "is_event"}, "VENDOR": {"key": "VENDOR", "json_field": "is_vendor"}, + "ORGANIZATION": {"key": "ORGANIZATION", "json_field": "is_organization"}, "AUTH": {"key": "AUTH", "json_field": "is_authentication"}, "TESTIMONIAL": {"key": "TESTIMONIAL", "json_field": "is_testimonial"}, "MEDIA": {"key": "MEDIA", "json_field": "is_media"}, diff --git a/src/api/urls.py b/src/api/urls.py index d8cc77359..f5c3bbfef 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -5,9 +5,11 @@ from api.handlers.campaign_account import CampaignAccountHandler # from api.handlers.email_templates import EmailTemplatesHandler from api.handlers.media_library import MediaLibraryHandler +from api.handlers.organization import OrganizationHandler from api.handlers.page_settings__aboutus import AboutUsPageSettingsHandler from api.handlers.page_settings__actions import ActionsPageSettingsHandler from api.handlers.page_settings__events import EventsPageSettingsHandler +from api.handlers.page_settings__organizations import OrganizationsPageSettingsHandler from api.handlers.page_settings__vendors import VendorsPageSettingsHandler from api.handlers.page_settings__testimonials import TestimonialsPageSettingsHandler from api.handlers.community import CommunityHandler @@ -79,6 +81,8 @@ UserHandler(), VendorHandler(), VendorsPageSettingsHandler(), + OrganizationHandler(), + OrganizationsPageSettingsHandler(), MediaLibraryHandler(), RegisterPageSettingsHandler(), SigninPageSettingsHandler(), diff --git a/src/api/utils/filter_functions.py b/src/api/utils/filter_functions.py index db786e21b..23189ae21 100644 --- a/src/api/utils/filter_functions.py +++ b/src/api/utils/filter_functions.py @@ -302,6 +302,34 @@ def get_vendor_filter_params(params): return [] +def get_organization_filter_params(params): + try: + query = [] + search_text = params.get("search_text", None) + + if search_text: + search= reduce( + operator.or_, ( + Q(name__icontains= search_text), + Q(communities__name__icontains= search_text), + Q(email__icontains= search_text), + Q(service_area__icontains= search_text), + )) + query.append(search) + + communities = params.get("communities serviced", None) + service_area= params.get('service area',None) + + if communities: + query.append(Q(communities__name__icontains=communities[0])) + if service_area: + query.append(Q(service_area__in=service_area)) + + return query + except Exception as e: + return [] + + def get_users_filter_params(params): try: query = [] diff --git a/src/database/models.py b/src/database/models.py index ca0cbb5dc..283ff62cf 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -1747,48 +1747,43 @@ class Meta: db_table = "carbon_equivalencies" -class Vendor(models.Model): +class Affiliate(models.Model): """ - A class used to represent a Vendor/Contractor that provides a service - associated with any of the actions. + A class used to generalize MassEnergize-approved groups such as Vendors and + Organizations with similar basic properties Attributes ---------- - name : str - name of the Vendor + name: str + name of the Affiliate description: str - description of this service + description of the Affiliate's role logo: int - Foreign Key to Media file represtenting the logo for this Vendor + Foreign Key to Media file representing the logo for this Affiliate banner: int - Foreign Key to Media file represtenting the banner for this Vendor + Foreign Key to Media file representing the banner for this Affiliate address: int - Foreign Key for Location of this Vendor + Foreign Key for Location of this Affiliate key_contact: int - Foreign Key for MassEnergize User that is the key contact for this vendor - service_area: str - Information about whether this vendor provides services nationally, - statewide, county or Town services only - properties_services: str - Whether this vendor services Residential or Commercial units only + Foreign Key for MassEnergize User that is the key contact for this Affiliate onboarding_date: DateTime - When this vendor was onboard-ed on the MassEnergize Platform for this + When this Affiliate was onboard-ed on the MassEnergize Platform for this community onboarding_contact: - Which MassEnergize Staff/User onboard-ed this vendor + Which MassEnergize Staff/User onboard-ed this Affiliate verification_checklist: contains information about some steps and checks needed for due diligence - to be done on this vendor eg. Vendor MOU, Reesearch + to be done on this Affiliate eg. Affiliate MOU, Reesearch is_verified: boolean When the checklist items are all done and verified then set this as True - to confirm this vendor + to confirm this Affiliate more_info: JSON - any another dynamic information we would like to store about this Service + any another dynamic information we would like to store about this Affiliate created_at: DateTime - The date and time that this Vendor was added + The date and time that this Affiliate was added created_at: DateTime The date and time of the last time any updates were made to the information - about this Vendor + about this Affiliate is_approved: boolean after the community admin reviews this, can check the box @@ -1804,7 +1799,7 @@ class Vendor(models.Model): blank=True, null=True, on_delete=models.SET_NULL, - related_name="vender_logo", + related_name="vendor_logo", ) banner = models.ForeignKey( Media, @@ -1815,10 +1810,6 @@ class Vendor(models.Model): ) address = models.JSONField(blank=True, null=True) key_contact = models.JSONField(blank=True, null=True) - service_area = models.CharField(max_length=SHORT_STR_LEN) - service_area_states = models.JSONField(blank=True, null=True) - services = models.ManyToManyField(Service, blank=True) - properties_serviced = models.JSONField(blank=True, null=True) onboarding_date = models.DateTimeField(auto_now_add=True) onboarding_contact = models.ForeignKey( UserProfile, @@ -1834,10 +1825,10 @@ class Vendor(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) communities = models.ManyToManyField( - Community, blank=True, related_name="community_vendors" + Community, blank=True, related_name="community_affiliates" ) - tags = models.ManyToManyField(Tag, related_name="vendor_tags", blank=True) - # which user posted this vendor + tags = models.ManyToManyField(Tag, related_name="affiliate_tags", blank=True) + # which user posted this Associate user = models.ForeignKey(UserProfile, on_delete=models.SET_NULL, null=True) is_deleted = models.BooleanField(default=False, blank=True) is_published = models.BooleanField(default=False, blank=True) @@ -1900,11 +1891,110 @@ def full_json(self): data["user_email"] = self.user.email return data + + +class Vendor(Affiliate): + """ + A class used to represent a Vendor/Contractor that provides a service + associated with any of the actions. + + Attributes + ---------- + name: str + name of the Vendor + description: str + description of this service + logo: int + Foreign Key to Media file represtenting the logo for this Vendor + banner: int + Foreign Key to Media file represtenting the banner for this Vendor + address: int + Foreign Key for Location of this Vendor + key_contact: int + Foreign Key for MassEnergize User that is the key contact for this vendor + service_area: str + Information about whether this vendor provides services nationally, + statewide, county or Town services only + properties_services: str + Whether this vendor services Residential or Commercial units only + onboarding_date: DateTime + When this vendor was onboard-ed on the MassEnergize Platform for this + community + onboarding_contact: + Which MassEnergize Staff/User onboard-ed this vendor + verification_checklist: + contains information about some steps and checks needed for due diligence + to be done on this vendor eg. Vendor MOU, Reesearch + is_verified: boolean + When the checklist items are all done and verified then set this as True + to confirm this vendor + more_info: JSON + any another dynamic information we would like to store about this Service + created_at: DateTime + The date and time that this Vendor was added + created_at: DateTime + The date and time of the last time any updates were made to the information + about this Vendor + is_approved: boolean + after the community admin reviews this, can check the box + + """ + + service_area = models.CharField(max_length=SHORT_STR_LEN) + service_area_states = models.JSONField(blank=True, null=True) + services = models.ManyToManyField(Service, blank=True) + properties_serviced = models.JSONField(blank=True, null=True) class Meta: db_table = "vendors" +class Organization(Affiliate): + """ + A class used to represent an Organization that provides a service + associated with any of the actions. + + Attributes + ---------- + name: str + name of the Organization + description: str + description of this service + logo: int + Foreign Key to Media file represtenting the logo for this Organization + banner: int + Foreign Key to Media file represtenting the banner for this Organization + address: int + Foreign Key for Location of this Organization + key_contact: int + Foreign Key for MassEnergize User that is the key contact for this Organization + onboarding_date: DateTime + When this Organization was onboard-ed on the MassEnergize Platform for this + community + onboarding_contact: + Which MassEnergize Staff/User onboard-ed this Organization + verification_checklist: + contains information about some steps and checks needed for due diligence + to be done on this Organization eg. Organization MOU, Reesearch + is_verified: boolean + When the checklist items are all done and verified then set this as True + to confirm this Organization + more_info: JSON + any another dynamic information we would like to store about this Service + created_at: DateTime + The date and time that this Organization was added + created_at: DateTime + The date and time of the last time any updates were made to the information + about this Organization + is_approved: boolean + after the community admin reviews this, can check the box + + """ + + class Meta: + db_table = "organizations" + + class Action(models.Model): """ A class used to represent an Action that can be taken by a user on this @@ -3573,6 +3663,21 @@ class Meta: db_table = "vendors_page_settings" verbose_name_plural = "VendorsPageSettings" +class OrganizationsPageSettings(PageSettings): + """ + Represents the community's Organizations page settings. + + Attributes + ---------- + see description under PageSettings + """ + + def __str__(self): + return "OrganizationsPageSettings - %s" % (self.community) + + class Meta: + db_table = "organizations_page_settings" + verbose_name_plural = "OrganizationsPageSettings" class EventsPageSettings(PageSettings): """ From a01a2410b5ebaa7e8893b172eade174d22ec8a49 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 12 May 2024 20:10:35 -0400 Subject: [PATCH 2/3] Implemented various Organization-related files (handlers, services, etc) --- src/api/handlers/organization.py | 249 +++++++++++ .../handlers/page_settings__organizations.py | 9 + src/api/services/organization.py | 139 +++++++ src/api/store/organization.py | 391 ++++++++++++++++++ src/api/tests/test_organizations.py | 241 +++++++++++ 5 files changed, 1029 insertions(+) create mode 100644 src/api/handlers/organization.py create mode 100644 src/api/handlers/page_settings__organizations.py create mode 100644 src/api/services/organization.py create mode 100644 src/api/store/organization.py create mode 100644 src/api/tests/test_organizations.py diff --git a/src/api/handlers/organization.py b/src/api/handlers/organization.py new file mode 100644 index 000000000..16c760be3 --- /dev/null +++ b/src/api/handlers/organization.py @@ -0,0 +1,249 @@ +"""Handler file for all routes pertaining to organizations""" + +from _main_.utils.route_handler import RouteHandler +import _main_.utils.common as utils +from _main_.utils.common import get_request_contents, rename_field, parse_bool, parse_location, parse_list, validate_fields, parse_string +from api.services.organization import OrganizationService +from _main_.utils.massenergize_response import MassenergizeResponse +from _main_.utils.massenergize_errors import CustomMassenergizeError +from types import FunctionType as function +from _main_.utils.context import Context +from _main_.utils.validator import Validator +from api.decorators import admins_only, super_admins_only, login_required +from api.store.common import expect_media_fields + + + +class OrganizationHandler(RouteHandler): + + def __init__(self): + super().__init__() + self.service = OrganizationService() + self.registerRoutes() + + def registerRoutes(self): + self.add("/organizations.info", self.info) + self.add("/organizations.create", self.create) + self.add("/organizations.add", self.submit) + self.add("/organizations.submit", self.submit) + self.add("/organizations.list", self.list) + self.add("/organizations.update", self.update) + self.add("/organizations.copy", self.copy) + #self.add("/organizations.rank", self.rank) TODO + self.add("/organizations.delete", self.delete) + self.add("/organizations.remove", self.delete) + + #admin routes + self.add("/organizations.listForCommunityAdmin", self.community_admin_list) + self.add("/organizations.listForSuperAdmin", self.super_admin_list) + + + def info(self, request): + context: Context = request.context + args = context.get_request_body() + args = rename_field(args, 'organization_id', 'id') + organization_info, err = self.service.get_organization_info(context, args) + if err: + return err + return MassenergizeResponse(data=organization_info) + + @admins_only + def create(self, request): + + context: Context = request.context + args = context.get_request_body() + + (self.validator.expect("key_contact_name", str) + .expect("key_contact_email", str) + .expect("onboarding_contact_email", str) + .expect("name", str) + .expect("email", str) + .expect("phone_number", str) + .expect("have_address", bool) + .expect("is_verified", bool) + .expect("website", str, is_required=False) + .expect("is_published", bool) + .expect('is_approved', bool) + .expect("communities", list, is_required=False) + .expect("service_area_states", 'str_list', is_required=False) + .expect("properties_serviced", 'str_list', is_required=False) + .expect("image", "str_list", is_required=False) + .expect("tags", list, is_required=False) + .expect("location", "location", is_required=False) + ) + + args, err = self.validator.verify(args) + if err: + return err + + # not user submitted + args["is_approved"] = args.pop("is_approved", True) + + organization_info, err = self.service.create_organization(context, args) + if err: + return err + return MassenergizeResponse(data=organization_info) + + @login_required + def submit(self, request): + + context: Context = request.context + args = context.get_request_body() + + (self.validator.expect("key_contact_name", str) + .expect("key_contact_email", str) + .expect("onboarding_contact_email", str) + .expect("name", str) + .expect("email", str) + .expect("phone_number", str) + .expect("have_address", bool) + .expect("is_verified", bool) + .expect("website", str, is_required=False) + .expect("is_published", bool) + .expect("communities", list, is_required=False) + .expect("service_area_states", 'str_list', is_required=False) + .expect("properties_serviced", 'str_list', is_required=False) + .expect("image", "file", is_required=False) + .expect("tags", list, is_required=False) + .expect("location", str, is_required=False) + .expect("organization_id", str) + + ) + # self.validator.expect("size", str) + # self.validator.expect("size_text", str) + # self.validator.expect("description") + # self.validator.expect("underAge", bool) + # self.validator.expect("copyright", bool) + # self.validator.expect("copyright_att", str) + # self.validator.expect("guardian_info", str) + + self = expect_media_fields(self) + + args, err = self.validator.verify(args) + if err: + return err + + # user submitted organization, so notify the community admins + user_submitted = True + is_edit = args.get("organization_id", None) + + if is_edit: + organization_info, err = self.service.update_organization(context, args, user_submitted) + else: + organization_info, err = self.service.create_organization(context, args, user_submitted) + if err: + return err + return MassenergizeResponse(data=organization_info) + + + def list(self, request): + context: Context = request.context + args = context.get_request_body() + organization_info, err = self.service.list_organizations(context, args) + if err: + return err + return MassenergizeResponse(data=organization_info) + + @login_required + def update(self, request): + context: Context = request.context + args = context.get_request_body() + (self.validator + .rename("id","organization_id") + .expect("organization_id", int) + .expect("key_contact_name", str, is_required=False) + .expect("key_contact_email", str, is_required=False) + .expect("onboarding_contact_email", str, is_required=False) + .expect("name", str, is_required=False) + .expect("email", str, is_required=False) + .expect("website", str, is_required=False) + .expect("is_verified", bool, is_required=False) + .expect("phone_number", str, is_required=False) + .expect("have_address", bool, is_required=False) + .expect("is_published", bool, is_required=False) + .expect("is_approved", bool, is_required=False) + .expect("communities", list, is_required=False) + .expect("service_area_states", 'str_list', is_required=False) + .expect("properties_serviced", 'str_list', is_required=False) + .expect("tags", list, is_required=False) + .expect("image", "str_list", is_required=False) + .expect("location", "location", is_required=False) + ) + + self = expect_media_fields(self) + args, err = self.validator.verify(args) + if err: + return err + + organization_info, err = self.service.update_organization(context, args) + if err: + return err + return MassenergizeResponse(data=organization_info) + + @admins_only + def rank(self, request): + context: Context = request.context + args: dict = context.args + + self.validator.expect('id', int, is_required=True) + self.validator.expect('rank', int, is_required=True) + self.validator.rename('organization_id', 'id') + + args, err = self.validator.verify(args) + if err: + return err + + organization_info, err = self.service.rank_organization(args,context) + if err: + return err + return MassenergizeResponse(data=organization_info) + + @admins_only + def delete(self, request): + context: Context = request.context + args: dict = context.args + args = rename_field(args, 'organization_id', 'id') + organization_id = args.pop('id', None) + if not organization_id: + return CustomMassenergizeError("Please Provide Organization Id") + organization_info, err = self.service.delete_organization(organization_id,context) + if err: + return err + return MassenergizeResponse(data=organization_info) + + @admins_only + def copy(self, request): + context: Context = request.context + args: dict = context.args + organization_id = args.get('organization_id', None) + + if not organization_id: + return CustomMassenergizeError("Please Provide Organization Id") + organization_info, err = self.service.copy_organization(context, args) + if err: + return err + return MassenergizeResponse(data=organization_info) + + @admins_only + def community_admin_list(self, request): + context: Context = request.context + args: dict = context.args + + self.validator.expect("community_id", int, is_required=False) + + args, err = self.validator.verify(args) + if err: + return err + organizations, err = self.service.list_organizations_for_community_admin(context, args) + + if err: + return err + return MassenergizeResponse(data=organizations) + + @super_admins_only + def super_admin_list(self, request): + context: Context = request.context + organizations, err = self.service.list_organizations_for_super_admin(context) + if err: + return err + return MassenergizeResponse(data=organizations) diff --git a/src/api/handlers/page_settings__organizations.py b/src/api/handlers/page_settings__organizations.py new file mode 100644 index 000000000..e42aa043e --- /dev/null +++ b/src/api/handlers/page_settings__organizations.py @@ -0,0 +1,9 @@ +"""Handler file for all routes pertaining to organizations_page_settings""" + +from database.models import OrganizationsPageSettings +from api.handlers.page_settings import PageSettingsHandler + +class OrganizationsPageSettingsHandler(PageSettingsHandler): + + def __init__(self): + super().__init__('organizations', OrganizationsPageSettings) \ No newline at end of file diff --git a/src/api/services/organization.py b/src/api/services/organization.py new file mode 100644 index 000000000..a4173240b --- /dev/null +++ b/src/api/services/organization.py @@ -0,0 +1,139 @@ +from _main_.utils.massenergize_errors import MassEnergizeAPIError, CustomMassenergizeError +from _main_.utils.common import serialize, serialize_all +from _main_.utils.pagination import paginate +from api.store.organization import OrganizationStore +from _main_.utils.context import Context +from _main_.utils.constants import ADMIN_URL_ROOT +from _main_.settings import SLACK_SUPER_ADMINS_WEBHOOK_URL, IS_PROD, IS_CANARY +from _main_.utils.emailer.send_email import send_massenergize_rich_email +from api.utils.api_utils import get_sender_email +from api.utils.filter_functions import sort_items +from .utils import send_slack_message +from api.store.utils import get_user_or_die, get_community_or_die +from sentry_sdk import capture_message +from typing import Tuple + +class OrganizationService: + """ + Service Layer for all the organizations + """ + + def __init__(self): + self.store = OrganizationStore() + + def get_organization_info(self, context, organization_id) -> Tuple[dict, MassEnergizeAPIError]: + organization, err = self.store.get_organization_info(context, organization_id) + if err: + return None, err + return serialize(organization, full=True), None + + def list_organizations(self, context, args) -> Tuple[list, MassEnergizeAPIError]: + organizations, err = self.store.list_organizations(context, args) + if err: + return None, err + return serialize_all(organizations), None + + + def create_organization(self, context, args, user_submitted=False) -> Tuple[dict, MassEnergizeAPIError]: + try: + if user_submitted: + # this should be coming from a community site + community = get_community_or_die(context, args) + if not community: + return None, CustomMassenergizeError('Organization submission requires a community') + + organization, err = self.store.create_organization(context, args,user_submitted) + if err: + return None, err + + if user_submitted: + + # For now, send e-mail to primary community contact for a site + admin_email = community.owner_email + admin_name = community.owner_name + first_name = admin_name.split(" ")[0] + if not first_name or first_name == "": + first_name = admin_name + + community_name = community.name + + user = get_user_or_die(context, args) + if user: + name = user.full_name + email = user.email + else: + return None, CustomMassenergizeError('Organization submission incomplete') + + subject = 'User Service Provider Submitted' + + content_variables = { + 'name': first_name, + 'community_name': community_name, + 'url': f"{ADMIN_URL_ROOT}/admin/edit/{organization.id}/organization", + 'from_name': name, + 'email': email, + 'title': organization.name, + 'body': organization.description, + } + send_massenergize_rich_email( + subject, admin_email, 'organization_submitted_email.html', content_variables, None) + + if IS_PROD or IS_CANARY: + send_slack_message( + #SLACK_COMMUNITY_ADMINS_WEBHOOK_URL, { + SLACK_SUPER_ADMINS_WEBHOOK_URL, { + "content": "User submitted Organization for "+community_name, + "from_name": name, + "email": email, + "subject": organization.name, + "message": organization.description, + "url": f"{ADMIN_URL_ROOT}/admin/edit/{organization.id}/organization", + "community": community_name + }) + + return serialize(organization), None + + except Exception as e: + capture_message(str(e), level="error") + return None, CustomMassenergizeError(e) + + def update_organization(self, context, args, user_submitted=False) -> Tuple[dict, MassEnergizeAPIError]: + organization, err = self.store.update_organization(context, args, user_submitted) + if err: + return None, err + return serialize(organization), None + + def rank_organization(self, args,context) -> Tuple[dict, MassEnergizeAPIError]: + organization, err = self.store.rank_organization(args,context) + if err: + return None, err + return serialize(organization), None + + + def copy_organization(self, context: Context, args) -> Tuple[dict, MassEnergizeAPIError]: + organization, err = self.store.copy_organization(context, args) + if err: + return None, err + return serialize(organization), None + + def delete_organization(self, organization_id,context) -> Tuple[dict, MassEnergizeAPIError]: + organization, err = self.store.delete_organization(organization_id,context) + if err: + return None, err + return serialize(organization), None + + + def list_organizations_for_community_admin(self, context: Context, args) -> Tuple[list, MassEnergizeAPIError]: + organizations, err = self.store.list_organizations_for_community_admin(context, args) + if err: + return None, err + sorted = sort_items(organizations, context.get_params()) + return paginate(sorted, context.get_pagination_data()), None + + + def list_organizations_for_super_admin(self, context: Context) -> Tuple[list, MassEnergizeAPIError]: + organizations, err = self.store.list_organizations_for_super_admin(context) + if err: + return None, err + sorted = sort_items(organizations, context.get_params()) + return paginate(sorted, context.get_pagination_data()), None diff --git a/src/api/store/organization.py b/src/api/store/organization.py new file mode 100644 index 000000000..c0065dec4 --- /dev/null +++ b/src/api/store/organization.py @@ -0,0 +1,391 @@ +from _main_.utils.footage.FootageConstants import FootageConstants +from _main_.utils.footage.spy import Spy +from api.store.common import get_media_info, make_media_info +from api.tests.common import RESET, makeUserUpload +from api.utils.filter_functions import get_organization_filter_params +from database.models import Organization, UserProfile, Media, Community +from _main_.utils.massenergize_errors import MassEnergizeAPIError, NotAuthorizedError, InvalidResourceError, CustomMassenergizeError +from _main_.utils.context import Context +from .utils import get_community_or_die, get_admin_communities, get_new_title +from _main_.utils.context import Context +from sentry_sdk import capture_message +from typing import Tuple +from django.db.models import Q + + +class OrganizationStore: + def __init__(self): + self.name = "Organization Store/DB" + + def get_organization_info(self, context, args) -> Tuple[dict, MassEnergizeAPIError]: + try: + organization_id = args.pop('organization_id', None) or args.pop('id', None) + + if not organization_id: + return None, InvalidResourceError() + organization = Organization.objects.filter(pk=organization_id).first() + + if not organization: + return None, InvalidResourceError() + + return organization, None + except Exception as e: + capture_message(str(e), level="error") + return None, CustomMassenergizeError(e) + + + def list_organizations(self, context: Context, args) -> Tuple[list, MassEnergizeAPIError]: + try: + subdomain = args.pop('subdomain', None) + community_id = args.pop('community_id', None) + + if community_id and community_id!='undefined': + community = Community.objects.get(pk=community_id) + elif subdomain: + community = Community.objects.get(subdomain=subdomain) + else: + community = None + + if not community: + return [], None + + organizations = community.community_organizations.filter(is_deleted=False) + + if not context.is_sandbox: + if context.user_is_logged_in and not context.user_is_admin(): + organizations = organizations.filter(Q(user__id=context.user_id) | Q(is_published=True)) + else: + organizations = organizations.filter(is_published=True) + + return organizations, None + except Exception as e: + capture_message(str(e), level="error") + return None, CustomMassenergizeError(e) + + def create_organization(self, context: Context, args, user_submitted) -> Tuple[Organization, MassEnergizeAPIError]: + try: + image_info = make_media_info(args) + tags = args.pop('tags', []) + communities = args.pop('communities', []) + images = args.pop('image', None) + website = args.pop('website', None) + user_email = args.pop('user_email', context.user_email) + onboarding_contact_email = args.pop('onboarding_contact_email', None) + key_contact_name = args.pop('key_contact_name', None) + key_contact_email = args.pop('key_contact_email', None) + args["key_contact"] = { + "name": key_contact_name, + "email": key_contact_email + } + + have_address = args.pop('have_address', False) + if not have_address: + args['location'] = None + + new_organization = Organization.objects.create(**args) + + if communities: + new_organization.communities.set(communities) + + user_media_upload = None + if images: + if user_submitted: + name=f"ImageFor {new_organization.name} Organization" + logo = Media.objects.create(name=name, file=images) + user_media_upload = makeUserUpload(media = logo,info=image_info,communities=new_organization.communities) + + else: + logo = Media.objects.filter(pk = images[0]).first() + new_organization.logo = logo + + if onboarding_contact_email: + onboarding_contact = UserProfile.objects.filter(email=onboarding_contact_email).first() + if onboarding_contact: + new_organization.onboarding_contact = onboarding_contact + + user = None + if user_email: + user_email = user_email.strip() + # verify that provided emails are valid user + if not UserProfile.objects.filter(email=user_email).exists(): + return None, CustomMassenergizeError(f"Email: {user_email} is not registered with us") + + user = UserProfile.objects.filter(email=user_email).first() + if user: + new_organization.user = user + if user_media_upload: + user_media_upload.user = user + user_media_upload.save() + + if website: + new_organization.more_info = {'website': website} + + new_organization.save() + + + + if tags: + new_organization.tags.set(tags) + + new_organization.save() + # ---------------------------------------------------------------- + Spy.create_organization_footage(organizations = [new_organization], context = context, actor = new_organization.user, type = FootageConstants.create(), notes =f"Organization ID({new_organization.id})") + # ---------------------------------------------------------------- + return new_organization, None + except Exception as e: + capture_message(str(e), level="error") + return None, CustomMassenergizeError(e) + + def update_organization(self, context: Context, args, user_submitted) -> Tuple[dict, MassEnergizeAPIError]: + + try: + image_info = make_media_info(args) + organization_id = args.pop('organization_id', None) + organizations = Organization.objects.filter(id=organization_id) + if not organizations: + return None, InvalidResourceError() + organization = organizations.first() + + # checks if requesting user is the organization creator, super admin or community admin else throw error + if str(organization.user_id) != context.user_id and not context.user_is_super_admin and not context.user_is_community_admin: + return None, NotAuthorizedError() + + communities = args.pop('communities', []) + onboarding_contact_email = args.pop('onboarding_contact_email', None) + website = args.pop('website', None) + key_contact_name = args.pop('key_contact_name', None) + key_contact_email = args.pop('key_contact_email', None) + key_contact = { + "name": key_contact_name, + "email": key_contact_email + } + images = args.pop('image', None) + tags = args.pop('tags', []) + have_address = args.pop('have_address', False) + if not have_address: + args['location'] = None + is_published = args.pop('is_published', None) + + organizations.update(**args) + organization = organizations.first() # refresh after update + + if communities: + organization.communities.set(communities) + + if onboarding_contact_email: + organization.onboarding_contact_email = onboarding_contact_email + + if key_contact: + if organization.key_contact: + organization.key_contact.update(key_contact) + else: + organization.key_contact = key_contact + + if images: #now, images will always come as an array of ids, or "reset" string + if user_submitted: + if "ImgToDel" in images: + organization.logo = None + else: + image= Media.objects.create(file=images, name=f'ImageFor {organization.name} Organization') + organization.logo = image + makeUserUpload(media = image,info=image_info, user=organization.user,communities=organization.communities) + + else: + if images[0] == RESET: #if image is reset, delete the existing image + organization.logo = None + else: + media = Media.objects.filter(id = image[0]).first() + organization.logo = media + + if organization.logo: + old_image_info, can_save_info = get_media_info(organization.logo) + if can_save_info: + organization.logo.user_upload.info.update({**old_image_info,**image_info}) + organization.logo.user_upload.save() + + + if onboarding_contact_email: + onboarding_contact = UserProfile.objects.filter(email=onboarding_contact_email).first() + if onboarding_contact: + organization.onboarding_contact = onboarding_contact + + if tags: + organization.tags.set(tags) + + if website: + organization.more_info = {'website': website} + + # temporarily back out this logic until we have user submitted organizations + ###if is_published==False: + ### organization.is_published = False + ### + ###elif is_published and not organization.is_published: + ### # only publish organization if it has been approved + ### if organization.is_approved: + ### organization.is_published = True + ### else: + ### return None, CustomMassenergizeError("Service provider needs to be approved before it can be made live") + if is_published != None: + organization.is_published = is_published + if organization.is_approved==False and is_published: + organization.is_approved==True # Approve an organization if an admin publishes it + + + organization.save() + # ---------------------------------------------------------------- + Spy.create_organization_footage(organizations = [organization], context = context, type = FootageConstants.update(), notes =f"Organization ID({organization_id})") + # ---------------------------------------------------------------- + return organization, None + + except Exception as e: + capture_message(str(e), level="error") + return None, CustomMassenergizeError(e) + + def rank_organization(self, args, context) -> Tuple[dict, MassEnergizeAPIError]: + try: + id = args.get("id", None) + rank = args.get("rank", None) + + if id and rank: + organizations = Organization.objects.filter(id=id) + organizations.update(rank=rank) + organization = organizations.first() + # ---------------------------------------------------------------- + Spy.create_event_footage(organizations = [organization], context = context, type = FootageConstants.update(), notes=f"Rank updated to - {rank}") + # ---------------------------------------------------------------- + return organization, None + else: + raise Exception("Rank and ID not provided to organizations.rank") + except Exception as e: + capture_message(str(e), level="error") + return None, CustomMassenergizeError(e) + + + def delete_organization(self, organization_id, context) -> Tuple[dict, MassEnergizeAPIError]: + try: + organizations = Organization.objects.filter(id=organization_id) + organizations.update(is_deleted=True) + #TODO: also remove it from all places that it was ever set in many to many or foreign key + organization = organizations.first() + # ---------------------------------------------------------------- + Spy.create_organization_footage(organizations = [organization], context = context, type = FootageConstants.delete(), notes =f"Deleted ID({organization_id})") + # ---------------------------------------------------------------- + return organization, None + except Exception as e: + capture_message(str(e), level="error") + return None, CustomMassenergizeError(e) + + + def copy_organization(self, context: Context, args) -> Tuple[Organization, MassEnergizeAPIError]: + try: + organization_id = args.get("organization_id", None) + organization: Organization = Organization.objects.get(id=organization_id) + if not organization: + return None, InvalidResourceError() + + # the copy will have "-Copy" appended to the name; if that already exists, keep it but update specifics + new_name = get_new_title(None, organization.name) + "-Copy" + existing_organization = Organization.objects.filter(name=new_name).first() + if existing_organization: + # keep existing event with that name + new_organization = existing_organization + # copy specifics from the event to copy + new_organization.phone_number = organization.phone_number + new_organization.email = organization.email + new_organization.description = organization.description + new_organization.logo = organization.logo + new_organization.banner = organization.banner + new_organization.address = organization.address + new_organization.key_contact = organization.key_contact + new_organization.service_area = organization.service_area + new_organization.service_area_states = organization.service_area_states + new_organization.properties_serviced = organization.properties_serviced + new_organization.onboarding_date = organization.onboarding_date + new_organization.onboarding_contact = organization.onboarding_contact + new_organization.verification_checklist = organization.verification_checklist + new_organization.location = organization.location + new_organization.more_info = organization.more_info + + else: + new_organization = organization + new_organization.pk = None + + new_organization.name = new_name + new_organization.is_published = False + new_organization.is_verified = False + + # keep record of who made the copy + if context.user_email: + user = UserProfile.objects.filter(email=context.user_email).first() + if user: + new_organization.user = user + + new_organization.save() + + for tag in organization.tags.all(): + new_organization.tags.add(tag) + new_organization.save() + # ---------------------------------------------------------------- + Spy.create_organization_footage(organizations = [new_organization,new_organization], context = context, type = FootageConstants.copy(), notes =f"Copied from ID({organization_id}) to ({new_organization.id})" ) + # ---------------------------------------------------------------- + return new_organization, None + except Exception as e: + capture_message(str(e), level="error") + return None, CustomMassenergizeError(e) + + + def list_organizations_for_community_admin(self, context: Context, args) -> Tuple[list, MassEnergizeAPIError]: + try: + if context.user_is_super_admin: + return self.list_organizations_for_super_admin(context) + + elif not context.user_is_community_admin: + return None, NotAuthorizedError() + + # community_id coming from admin portal as "null" + community_id = args.pop('community_id', None) + if community_id == 0: + # return actions from all communities + return self.list_organizations_for_super_admin(context) + + + filter_params = get_organization_filter_params(context.get_params()) + + if not community_id: + # different code in action.py/event.py + #user = UserProfile.objects.get(pk=context.user_id) + #admin_groups = user.communityadmingroup_set.all() + #comm_ids = [ag.community.id for ag in admin_groups] + #organizations = Organization.objects.filter(community__id__in = comm_ids, is_deleted=False).select_related('logo', 'community') + communities, err = get_admin_communities(context) + organizations = None + for c in communities: + if organizations is not None: + organizations |= c.community_organizations.filter(is_deleted=False, *filter_params).select_related('logo').prefetch_related('communities', 'tags') + else: + organizations = c.community_organizations.filter(is_deleted=False,*filter_params).select_related('logo').prefetch_related('communities', 'tags') + + if organizations: + organizations = organizations.exclude(more_info__icontains='"created_via_campaign": true').distinct() + + return organizations, None + + community = get_community_or_die(context, {'community_id': community_id}) + organizations = community.community_organizations.filter(is_deleted=False,*filter_params).select_related('logo').prefetch_related('communities', 'tags') + if organizations: + organizations = organizations.exclude(more_info__icontains='"created_via_campaign": true').distinct() + return organizations, None + except Exception as e: + capture_message(str(e), level="error") + return None, CustomMassenergizeError(e) + + + def list_organizations_for_super_admin(self, context: Context): + try: + + filter_params = get_organization_filter_params(context.get_params()) + organizations = Organization.objects.filter(is_deleted=False, *filter_params).select_related('logo').prefetch_related('communities', 'tags') + return organizations.exclude(more_info__icontains='"created_via_campaign": true').distinct(), None + except Exception as e: + capture_message(str(e), level="error") + return None, CustomMassenergizeError(e) diff --git a/src/api/tests/test_organizations.py b/src/api/tests/test_organizations.py new file mode 100644 index 000000000..eebab59b9 --- /dev/null +++ b/src/api/tests/test_organizations.py @@ -0,0 +1,241 @@ +from django.test import TestCase, Client +from django.conf import settings as django_settings +from urllib.parse import urlencode +from _main_.utils.constants import DEFAULT_PAGINATION_LIMIT +from database.models import Team, Community, UserProfile, CommunityAdminGroup, Organization +from api.tests.common import signinAs, setupCC, createUsers + +class OrganizationTestCase(TestCase): + + @classmethod + def setUpClass(self): + + print("\n---> Testing Organizations <---\n") + + self.client = Client() + + self.USER, self.CADMIN, self.SADMIN = createUsers() + + signinAs(self.client, self.SADMIN) + + setupCC(self.client) + + COMMUNITY_NAME = "test_organizations" + self.COMMUNITY = Community.objects.create(**{ + 'subdomain': COMMUNITY_NAME, + 'name': COMMUNITY_NAME.capitalize(), + 'owner_email': 'no-reply@massenergize.org', + 'owner_name': 'Community Owner', + 'accepted_terms_and_conditions': True + }) + + self.USER1 = UserProfile.objects.create(**{ + 'full_name': "Organization Tester", + 'email': 'organization@tester.com' + }) + + admin_group_name = f"{self.COMMUNITY.name}-{self.COMMUNITY.subdomain}-Admin-Group" + self.COMMUNITY_ADMIN_GROUP = CommunityAdminGroup.objects.create(name=admin_group_name, community=self.COMMUNITY) + self.COMMUNITY_ADMIN_GROUP.members.add(self.CADMIN) + + self.ORGANIZATION1 = Organization.objects.create(name="organization1") + self.ORGANIZATION1.communities.set([self.COMMUNITY]) + self.ORGANIZATION2 = Organization.objects.create(name="organization2") + self.ORGANIZATION2.communities.set([self.COMMUNITY]) + + self.ORGANIZATION1.user = self.USER1 + + self.ORGANIZATION1.save() + self.ORGANIZATION2.save() + + # a user submitted organization + signinAs(self.client, self.USER) + response = self.client.post('/api/organizations.add', urlencode({"name": "User Submitted Organization", "community_id":self.COMMUNITY.id}), content_type="application/x-www-form-urlencoded").toDict() + self.SUBMITTED_ORGANIZATION_ID = response["data"]["id"] + + + @classmethod + def tearDownClass(self): + pass + + + def setUp(self): + # this gets run on every test case + pass + + def test_info(self): + # test not logged in + signinAs(self.client, None) + response = self.client.post('/api/organizations.info', urlencode({"organization_id": self.ORGANIZATION1.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + # test logged as user + signinAs(self.client, self.USER) + response = self.client.post('/api/organizations.info', urlencode({"organization_id": self.ORGANIZATION1.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + # test logged as admin + signinAs(self.client, self.SADMIN) + response = self.client.post('/api/organizations.info', urlencode({"organization_id": self.ORGANIZATION1.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + def test_create(self): + # test not logged in + signinAs(self.client, None) + response = self.client.post('/api/organizations.create', urlencode({"name": "test_organization_1"}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as user + signinAs(self.client, self.USER) + response = self.client.post('/api/organizations.create', urlencode({"name": "test_organization_2"}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as admin + signinAs(self.client, self.SADMIN) + response = self.client.post('/api/organizations.create', urlencode({"name": "test_organization_3"}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + def test_list(self): + # test not logged in + signinAs(self.client, None) + response = self.client.post('/api/organizations.list', urlencode({"community_id": self.COMMUNITY.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + # test logged as user + signinAs(self.client, self.USER) + response = self.client.post('/api/organizations.list', urlencode({"community_id": self.COMMUNITY.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + # test logged as admin + signinAs(self.client, self.SADMIN) + response = self.client.post('/api/organizations.list', urlencode({"community_id": self.COMMUNITY.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + def test_update(self): + # test not logged in + signinAs(self.client, None) + response = self.client.post('/api/organizations.update', urlencode({"organization_id": self.ORGANIZATION1.id, "name": "updated_name"}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as user + signinAs(self.client, self.USER) + response = self.client.post('/api/organizations.update', urlencode({"organization_id": self.ORGANIZATION1.id, "name": "updated_name1"}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as user who submitted a organization + signinAs(self.client, self.USER1) + response = self.client.post('/api/organizations.update', urlencode({"organization_id": self.ORGANIZATION1.id, "name": "updated_name1"}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + self.assertEqual(response["data"]["name"], "updated_name1") + + # test logged as admin + signinAs(self.client, self.SADMIN) + response = self.client.post('/api/organizations.update', urlencode({"organization_id": self.ORGANIZATION1.id, "name": "updated_name2"}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + self.assertEqual(response["data"]["name"], "updated_name2") + + # test setting live but not yet approved ::BACKED-OUT :: + signinAs(self.client, self.CADMIN) + response = self.client.post('/api/organizations.update', urlencode({"organization_id": self.ORGANIZATION1.id, "is_published": "true"}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + self.assertEqual(response["data"]["is_published"], True) + # self.assertFalse(response["success"]) + + # test setting live and approved + response = self.client.post('/api/organizations.update', urlencode({"organization_id": self.ORGANIZATION1.id, "is_approved": "true", "is_published": "true"}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + + def test_copy(self): + # test not logged in + signinAs(self.client, None) + response = self.client.post('/api/organizations.copy', urlencode({"organization_id": self.ORGANIZATION1.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as user + signinAs(self.client, self.USER) + response = self.client.post('/api/organizations.copy', urlencode({"organization_id": self.ORGANIZATION1.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as admin + signinAs(self.client, self.SADMIN) + response = self.client.post('/api/organizations.copy', urlencode({"organization_id": self.ORGANIZATION2.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + # TODO when rank is added to organizations + def test_rank(self): + return + # test not logged in + signinAs(self.client, None) + response = self.client.post('/api/organizations.rank', urlencode({"organization_id": self.ORGANIZATION1.id, "rank": 1}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as user + signinAs(self.client, self.USER) + response = self.client.post('/api/organizations.rank', urlencode({"organization_id": self.ORGANIZATION1.id, "rank": 1}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as admin + signinAs(self.client, self.SADMIN) + response = self.client.post('/api/organizations.rank', urlencode({"organization_id": self.ORGANIZATION1.id, "rank": 1}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + def test_delete(self): + # test not logged in + organization = Organization.objects.create() + + signinAs(self.client, None) + response = self.client.post('/api/organizations.delete', urlencode({"organization_id": organization.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as user + signinAs(self.client, self.USER) + response = self.client.post('/api/organizations.delete', urlencode({"organization_id": organization.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as admin + signinAs(self.client, self.SADMIN) + response = self.client.post('/api/organizations.delete', urlencode({"organization_id": organization.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + def test_list_cadmin(self): + # test not logged in + signinAs(self.client, None) + response = self.client.post('/api/organizations.listForCommunityAdmin', urlencode({"community_id": self.COMMUNITY.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as user + signinAs(self.client, self.USER) + response = self.client.post('/api/organizations.listForCommunityAdmin', urlencode({"community_id": self.COMMUNITY.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as cadmin + signinAs(self.client, self.CADMIN) + response = self.client.post('/api/organizations.listForCommunityAdmin', urlencode({"community_id": self.COMMUNITY.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + # test logged as sadmin + signinAs(self.client, self.SADMIN) + response = self.client.post('/api/organizations.listForCommunityAdmin', urlencode({"community_id": self.COMMUNITY.id}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) + + def test_list_sadmin(self): + # test not logged in + signinAs(self.client, None) + response = self.client.post('/api/organizations.listForSuperAdmin', urlencode({}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as user + signinAs(self.client, self.USER) + response = self.client.post('/api/organizations.listForSuperAdmin', urlencode({}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as cadmin + signinAs(self.client, self.CADMIN) + response = self.client.post('/api/organizations.listForSuperAdmin', urlencode({}), content_type="application/x-www-form-urlencoded").toDict() + self.assertFalse(response["success"]) + + # test logged as sadmin + signinAs(self.client, self.SADMIN) + response = self.client.post('/api/organizations.listForSuperAdmin', urlencode({"limit":DEFAULT_PAGINATION_LIMIT}), content_type="application/x-www-form-urlencoded").toDict() + self.assertTrue(response["success"]) From bab1d2a3cefe913ae7b0dc4d3bbdf768dbaedce3 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 17 May 2024 17:03:49 -0400 Subject: [PATCH 3/3] Small adjustments to Organization functionality Fixed a redundant mention of service_area in filter_functions and added an allowed_users field in the Organization model class --- src/api/utils/filter_functions.py | 3 --- src/database/models.py | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/api/utils/filter_functions.py b/src/api/utils/filter_functions.py index 23189ae21..e7d809048 100644 --- a/src/api/utils/filter_functions.py +++ b/src/api/utils/filter_functions.py @@ -318,12 +318,9 @@ def get_organization_filter_params(params): query.append(search) communities = params.get("communities serviced", None) - service_area= params.get('service area',None) if communities: query.append(Q(communities__name__icontains=communities[0])) - if service_area: - query.append(Q(service_area__in=service_area)) return query except Exception as e: diff --git a/src/database/models.py b/src/database/models.py index f77a77c36..177939c1e 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -1988,9 +1988,13 @@ class Organization(Affiliate): about this Organization is_approved: boolean after the community admin reviews this, can check the box + allowed_users: + Users allowed to create events under the Organization """ + allowed_users = models.ManyToManyField(UserProfile, blank=True) + class Meta: db_table = "organizations"