diff --git a/requirements-dev.txt b/requirements-dev.txt index 1db77cd8..174ccc16 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,3 +6,4 @@ django-debug-toolbar==2.2 Faker==4.0.2 pytest==5.4.1 +ipython==7.14.0 diff --git a/ro_help/hub/admin.py b/ro_help/hub/admin.py index f163bb1b..b58e6185 100644 --- a/ro_help/hub/admin.py +++ b/ro_help/hub/admin.py @@ -85,11 +85,9 @@ class NGOAdmin(admin.ModelAdmin): form = NGOForm list_display = ("name", "contact_name", "county", "city", "accepts_transfer", "accepts_mobilpay", "created") - list_filter = ( - "city", - "county", - ) + list_filter = ("county",) search_fields = ("name", "email") + autocomplete_fields = ["city"] inlines = [NGOAccountInline] def get_queryset(self, request): @@ -102,14 +100,17 @@ def get_queryset(self, request): return qs def get_readonly_fields(self, request, obj=None): + readonly_fields = [] if not obj: - return [] + return readonly_fields user = request.user if not user.groups.filter(name=ADMIN_GROUP_NAME).exists(): - return ["users"] + readonly_fields.append("users") - return [] + readonly_fields.extend(["city_old", "county"]) + + return readonly_fields def has_accounts(self, request, no_accounts): for account_idx in range(no_accounts): @@ -202,7 +203,7 @@ class NGONeedAdmin(admin.ModelAdmin): list_display = ("title", "ngo", "urgency", "kind", "created", "responses", "resolved_on", "closed_on") list_filter = (NGOFilter, ActiveNGONeedFilter, "urgency", "kind", "ngo__city", "ngo__county") - readonly_fields = ["resolved_on", "closed_on"] + readonly_fields = ["resolved_on", "closed_on", "city_old", "county"] inlines = [NGOHelperInline] actions = ["resolve_need", "close_need"] search_fields = ( @@ -212,6 +213,7 @@ class NGONeedAdmin(admin.ModelAdmin): "ngo__name", "ngo__email", ) + autocomplete_fields = ["city"] class Media: pass @@ -331,10 +333,11 @@ class RegisterNGORequestAdmin(admin.ModelAdmin): "get_statute", ] actions = ["create_account", "close_request"] - readonly_fields = ["active", "resolved_on", "registered_on", "get_avatar"] + readonly_fields = ["active", "resolved_on", "registered_on", "get_avatar", "city_old", "county"] list_filter = ("city", "county", "registered_on", "closed") inlines = [RegisterNGORequestVoteInline] search_fields = ("name",) + autocomplete_fields = ["city"] def votes_count(self, obj): return obj.votes_count @@ -555,16 +558,17 @@ class CityAdmin(admin.ModelAdmin): At this moment, only the superadmin is allowed to import new cities and change existing ones. """ + list_display = ["city", "county"] list_filter = ["county", "is_county_residence"] search_fields = ["city"] def get_urls(self): urls = super().get_urls() - urls += [ + custom_url = [ url("import-cities", self.import_cities, name="import_cities"), ] - return urls + return custom_url + urls def has_add_permission(self, request): return False @@ -598,11 +602,7 @@ def import_cities(self, request): if (row["Judet"], row["Localitate"]) in COUNTY_RESIDENCE: is_county_residence = True - city = City( - city=row["Localitate"], - county=row["Judet"], - is_county_residence=is_county_residence - ) + city = City(city=row["Localitate"], county=row["Judet"], is_county_residence=is_county_residence) batch.append(city) if len(batch) == batch_size: @@ -614,7 +614,7 @@ def import_cities(self, request): City.objects.bulk_create(batch, batch_size=len(batch), ignore_conflicts=True) self.message_user(request, _("CSV file imported"), level=messages.INFO) - return redirect(".") + return redirect("..") form = ImportCitiesForm() context = { diff --git a/ro_help/hub/context_processors.py b/ro_help/hub/context_processors.py new file mode 100644 index 00000000..51d431fd --- /dev/null +++ b/ro_help/hub/context_processors.py @@ -0,0 +1,8 @@ +from django.conf import settings + + +def hub_settings(context): + return { + "DEBUG": settings.DEBUG, + "ANALYTICS_ENABLED": settings.ANALYTICS_ENABLED, + } diff --git a/ro_help/hub/forms.py b/ro_help/hub/forms.py index ad4d94ca..852ae214 100644 --- a/ro_help/hub/forms.py +++ b/ro_help/hub/forms.py @@ -1,12 +1,14 @@ from django import forms +from django.urls import reverse_lazy from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import ValidationError from django_crispy_bulma.widgets import EmailInput -from hub import models from captcha.fields import ReCaptchaField from captcha.widgets import ReCaptchaV3 +from hub import models + class NGOHelperForm(forms.ModelForm): captcha = ReCaptchaField(widget=ReCaptchaV3(attrs={"required_score": 0.3, "action": "help"}), label="") @@ -73,12 +75,27 @@ class Meta: ] widgets = { "email": EmailInput(), + "city": forms.Select(attrs={"data-url": reverse_lazy("city-autocomplete"),}), # # "has_netopia_contract": forms.CheckboxInput(), # "avatar": AdminResubmitImageWidget, # "last_balance_sheet": AdminResubmitFileWidget, # "statute": AdminResubmitFileWidget, } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["city"].queryset = models.City.objects.none() + + if "city" not in self.data: + self.fields["city"].widget.attrs.update({"disabled": "true"}) + + if "county" in self.data: + try: + county = self.data.get("county") + self.fields["city"].queryset = models.City.objects.filter(county__iexact=county) + except (ValueError, TypeError): + pass # invalid input, fallback to empty queryset + class RegisterNGORequestVoteForm(forms.ModelForm): class Meta: diff --git a/ro_help/hub/management/commands/seed.py b/ro_help/hub/management/commands/seed.py index de42e6fc..fe44f754 100644 --- a/ro_help/hub/management/commands/seed.py +++ b/ro_help/hub/management/commands/seed.py @@ -3,7 +3,6 @@ import requests from faker import Faker -from django.conf import settings from django.contrib.auth.models import User, Group, Permission from django.core.management.base import BaseCommand from django.utils import timezone @@ -24,6 +23,7 @@ DSU_GROUP_NAME, FFC_GROUP_NAME, NGOReportItem, + City, ) from mobilpay.models import PaymentOrder @@ -35,7 +35,7 @@ def random_avatar(): try: image = requests.get("https://source.unsplash.com/random", allow_redirects=False) return image.headers["Location"] - except: + except Exception: return "https://source.unsplash.com/random" @@ -45,31 +45,31 @@ def random_avatar(): "name": "Habitat for Humanity", "email": "habitat@habitat.ro", "description": """ - O locuință decentă poate rupe cercul sărăciei. Credem cu tărie în acest lucru din 1976, de când lucrăm pentru + O locuință decentă poate rupe cercul sărăciei. Credem cu tărie în acest lucru din 1976, de când lucrăm pentru viziunea noastră: o lume în care toți oamenii au posibilitatea să locuiască decent. Cu sprijinul nostru, peste 6 milioane de oameni din peste 70 de țări au un loc mai bun în care să trăiască, o casă nouă sau una - complet renovată.Suntem o asociație creștină, non-profit, ce lucrăm alături de oameni de pretutindeni, + complet renovată.Suntem o asociație creștină, non-profit, ce lucrăm alături de oameni de pretutindeni, din toate păturile sociale, rasele, religiile și naționalitățile pentru a elimina locuirea precară. """, "phone": "+40722644394", - "address": "Str. Naum Râmniceanu, nr. 45 A, et.1, ap. 3, sector 1, Bucureşti 011616", - "city": "Bucureşti", - "county": "Sector 1", + "address": "Str. Naum Râmniceanu, nr. 45 A, et.1, ap. 3, sector 1, Bucuresti 011616", + "city": "Bucuresti", + "county": "Bucuresti", "avatar": "http://www.habitat.ro/wp-content/uploads/2014/11/logo.png", }, { "name": "Crucea Roșie", "email": "matei@crucearosie.ro", "description": """ - Crucea Rosie Romana asista persoanele vulnerabile in situatii de dezastre si de criza. Prin programele si + Crucea Rosie Romana asista persoanele vulnerabile in situatii de dezastre si de criza. Prin programele si activitatile sale in beneficiul societatii, contribuie la prevenirea si alinarea suferintei sub toate formele, - protejeaza sanatatea si viata, promoveaza respectul fata de demnitatea umana, fara nicio discriminare bazata + protejeaza sanatatea si viata, promoveaza respectul fata de demnitatea umana, fara nicio discriminare bazata pe nationalitate, rasa, sex, religie, varsta, apartenenta sociala sau politica. """, "phone": "+40213176006", "address": "Strada Biserica Amzei, nr. 29, Sector 1, Bucuresti", "city": "Bucuresti", - "county": "Sector 1", + "county": "Bucuresti", "avatar": "https://crucearosie.ro/themes/redcross/images/emblema_crr_desktop.png", "accepts_transfer": True, "donations_description": "Monedă RON: RO44BRDE410SV20462054100 (Banca: BRD - Piata Romana)", @@ -78,19 +78,19 @@ def random_avatar(): "name": "MKBT: Make Better", "email": "contact@mkbt.ro", "description": """ - MKBT: Make Better has been working for urban development and regeneration in Romania since April 2014. - That is to say that we are drafting, validating and coordinating processes for local development and urban + MKBT: Make Better has been working for urban development and regeneration in Romania since April 2014. + That is to say that we are drafting, validating and coordinating processes for local development and urban regeneration in order to help as many cities become their best possible version and the best home for their inhabitants. As a local development advisor, we assist both public and private entities. - We substantiate our work on a thorough understanding of local needs and specificities of the communities we - work with. We acknowledge, at the same time, the global inter-connectivity of local challenges. Our proposed + We substantiate our work on a thorough understanding of local needs and specificities of the communities we + work with. We acknowledge, at the same time, the global inter-connectivity of local challenges. Our proposed solutions are therefore grounded in international best practice, while at the same time capitalizing – in a sustainable and harmonious manner – on local know how and resources. """, "phone": "+40213176006", "address": "Str. Popa Petre, Nr. 23, Sector 2, 020802, Bucharest, Romania.", "city": "Bucuresti", - "county": "Sector 2", + "county": "Bucuresti", "avatar": "http://mkbt.ro/wp-content/uploads/2015/08/MKBT-logo-alb.png", }, ] @@ -101,11 +101,11 @@ def random_avatar(): "description": fake.text(), "phone": fake.phone_number(), "address": fake.address(), - "city": random.choice(["Arad", "Timisoara", "Oradea", "Cluj", "Bucuresti"]), - "county": random.choice(["ARAD", "TIMIS", "BIHOR", "CLUJ", "SECTOR 1", "SECTOR 2"]), + "city": ["Arad", "Timisoara", "Oradea", "Cluj-Napoca", "Bucuresti"][i], + "county": ["Arad", "Timis", "Bihor", "Cluj", "Bucuresti"][i], "avatar": random_avatar(), } - for _ in range(2) + for i in range(5) ] ) @@ -205,7 +205,20 @@ def handle(self, *args, **kwargs): NGONeed.objects.filter(kind="money").exclude(ngo__name__in=["Code4Romania", "Crucea Roșie"]).delete() + cities = [ + {"city": "Arad", "county": "Arad"}, + {"city": "Timisoara", "county": "Timis"}, + {"city": "Oradea", "county": "Bihor"}, + {"city": "Cluj-Napoca", "county": "Cluj"}, + {"city": "Bucuresti", "county": "Bucuresti"}, + ] + for city_data in cities: + City.objects.get_or_create(**city_data) + for details in NGOS: + details["city"] = City.objects.get(city=details["city"], county=details["county"]) + details["county"] = details["county"].upper() + ngo, _ = NGO.objects.get_or_create(**details) owner = random.choice([ngo_user, admin_user, None]) @@ -214,6 +227,7 @@ def handle(self, *args, **kwargs): ngo.save() for _ in range(20): + random_city = City.objects.order_by("?").first() need = NGONeed.objects.create( **{ "ngo": ngo, @@ -222,8 +236,8 @@ def handle(self, *args, **kwargs): "description": fake.text(), "title": fake.text(), "resolved_on": random.choice([None, timezone.now()]), - "city": random.choice(["Arad", "Timisoara", "Oradea", "Cluj", "Bucuresti"]), - "county": random.choice(["ARAD", "TIMIS", "BIHOR", "CLUJ", "SECTOR 1", "SECTOR 2"]), + "city": random_city, + "county": random_city.county.upper(), } ) diff --git a/ro_help/hub/migrations/0049_models_to_city_model.py b/ro_help/hub/migrations/0049_models_to_city_model.py new file mode 100644 index 00000000..2fe40f7a --- /dev/null +++ b/ro_help/hub/migrations/0049_models_to_city_model.py @@ -0,0 +1,119 @@ +# Custom migration +from django.db import migrations, models +import django.db.models.deletion + + +def map_city_field_to_city_model(apps, schema_editor): + print(f"\n{'*'*80}\n") + print("[INFO]: START of data migration") + + City = apps.get_model("hub", "City") + + NGO = apps.get_model("hub", "NGO") + NGONeed = apps.get_model("hub", "NGONeed") + PersonalRequest = apps.get_model("hub", "PersonalRequest") + RegisterNGORequest = apps.get_model("hub", "RegisterNGORequest") + + models = [NGO, NGONeed, PersonalRequest, RegisterNGORequest] + + for model in models: + for n in model.objects.all(): + try: + if "sector" in n.county.lower(): + n.county = "BUCURESTI" + + city = n.city.lower().strip() + # remove romanian characters + for i in [("ț", "t"), ("ș", "s"), ("î", "i"), ("â", "a"), ("ă", "a")]: + city = city.replace(i[0], i[1]) + + # hack to format cities like "alba-iulia" properly + city = " ".join(city.split()).replace("-", " ").title().replace(" ", "-") + + # another fail-safe in case the "sector" one did not catch this + if city == "Bucuresti": + n.county = "BUCURESTI" + + city = City.objects.get(city__iexact=city, county__iexact=n.county) + n.city_fk = city + n.save() + print(f"[INFO]: Migrated: {n}") + except City.DoesNotExist: + print( + f"[ERROR]: Could not migrate properly: {n}. " + f"City object with fields {n.city} ({n.county}) not found." + ) + + print("[INFO]: END of data migration\n") + print(f"\n{'*'*80}\n") + + +class Migration(migrations.Migration): + + dependencies = [ + ("hub", "0048_auto_20200429_0736"), + ] + + operations = [ + migrations.AddField( + model_name="ngo", + name="city_fk", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.PROTECT, to="hub.City", verbose_name="City", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="ngoneed", + name="city_fk", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.PROTECT, to="hub.City", verbose_name="City", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="personalrequest", + name="city_fk", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.PROTECT, to="hub.City", verbose_name="City", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="registerngorequest", + name="city_fk", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.PROTECT, to="hub.City", verbose_name="City", + ), + preserve_default=False, + ), + # data migration + migrations.RunPython(map_city_field_to_city_model), + migrations.RenameField(model_name="ngo", old_name="city", new_name="city_old",), + migrations.RenameField(model_name="ngo", old_name="city_fk", new_name="city",), + migrations.RenameField(model_name="ngoneed", old_name="city", new_name="city_old",), + migrations.RenameField(model_name="ngoneed", old_name="city_fk", new_name="city",), + migrations.RenameField(model_name="personalrequest", old_name="city", new_name="city_old",), + migrations.RenameField(model_name="personalrequest", old_name="city_fk", new_name="city",), + migrations.RenameField(model_name="registerngorequest", old_name="city", new_name="city_old",), + migrations.RenameField(model_name="registerngorequest", old_name="city_fk", new_name="city",), + migrations.AlterModelOptions(name="city", options={"verbose_name": "City", "verbose_name_plural": "cities"},), + migrations.AlterField( + model_name="ngo", name="city_old", field=models.CharField(max_length=100, verbose_name="City (legacy)"), + ), + migrations.AlterField( + model_name="ngoneed", + name="city_old", + field=models.CharField(max_length=100, verbose_name="City (legacy)"), + ), + migrations.AlterField( + model_name="personalrequest", + name="city_old", + field=models.CharField(max_length=100, verbose_name="City (legacy)"), + ), + migrations.AlterField( + model_name="registerngorequest", + name="city_old", + field=models.CharField(max_length=100, verbose_name="City (legacy)"), + ), + ] diff --git a/ro_help/hub/models.py b/ro_help/hub/models.py index a716acaf..422ef448 100644 --- a/ro_help/hub/models.py +++ b/ro_help/hub/models.py @@ -195,11 +195,12 @@ class City(models.Model): is_county_residence = models.BooleanField(_("Is county residence"), default=False) class Meta: + verbose_name = _("City") verbose_name_plural = _("cities") unique_together = ["city", "county"] def __str__(self): - return self.city + return f"{self.city} ({self.county})" def save(self, *args, **kwargs): self.is_county_residence = False @@ -237,7 +238,8 @@ class NGO(TimeStampedModel): cif = models.CharField("CIF", max_length=20, null=True, blank=True) cui = models.CharField("CUI", max_length=20, null=True, blank=True) county = models.CharField(_("County"), choices=COUNTY.to_choices(), max_length=50) - city = models.CharField(_("City"), max_length=100) + city = models.ForeignKey("City", on_delete=models.PROTECT, null=True, verbose_name=_("City")) + city_old = models.CharField(_("City (legacy)"), max_length=100) avatar = models.ImageField(_("Logo"), max_length=300, storage=PublicMediaStorageClass()) last_balance_sheet = models.FileField( @@ -277,7 +279,7 @@ def get_funders(self): def get_total_funded(self): num_funders = self.payment_orders.filter(success=True).count() if num_funders: - sum_funded = int(self.payment_orders.filter(success=True).aggregate(models.Sum('amount'))['amount__sum']) + sum_funded = int(self.payment_orders.filter(success=True).aggregate(models.Sum("amount"))["amount__sum"]) else: sum_funded = 0 return num_funders, sum_funded @@ -290,6 +292,14 @@ def get_avatar(self): else: return None + def save(self, *args, **kwargs): + if self.city: + self.county = self.city.county.upper() + else: + self.county = "" + + super().save(*args, **kwargs) + class Meta: verbose_name_plural = _("My organizations") verbose_name = _("My organization") @@ -346,7 +356,8 @@ class NGONeed(TimeStampedModel): kind = models.CharField(_("Kind"), choices=KIND.to_choices(), default=KIND.default(), max_length=10) urgency = models.CharField(_("Urgency"), choices=URGENCY.to_choices(), default=URGENCY.default(), max_length=10) - city = models.CharField(_("City"), max_length=100) + city = models.ForeignKey("City", on_delete=models.PROTECT, null=True, verbose_name=_("City")) + city_old = models.CharField(_("City (legacy)"), max_length=100) county = models.CharField(_("County"), choices=COUNTY.to_choices(), max_length=50) resource_tags = models.ManyToManyField("ResourceTag", blank=True, related_name="needs") @@ -359,6 +370,14 @@ class NGONeed(TimeStampedModel): def __str__(self): return f"{self.ngo.name}: {self.urgency} {self.kind}" + def save(self, *args, **kwargs): + if self.city: + self.county = self.city.county.upper() + else: + self.county = "" + + super().save(*args, **kwargs) + class Meta: verbose_name_plural = _("Manage needs") verbose_name = _("Manage need") @@ -404,7 +423,8 @@ class PersonalRequest(TimeStampedModel): name = models.CharField(_("Name"), max_length=254,) email = models.EmailField(_("Email"), null=True, blank=True) phone = models.CharField(_("Phone"), max_length=15) - city = models.CharField(_("City"), max_length=100) + city = models.ForeignKey("City", on_delete=models.PROTECT, null=True, verbose_name=_("City")) + city_old = models.CharField(_("City (legacy)"), max_length=100) county = models.CharField(_("County"), choices=COUNTY.to_choices(), max_length=50) address = models.CharField(_("Address"), max_length=400, null=True, blank=True) organization = models.CharField(_("Organization"), max_length=400, null=True, blank=True) @@ -416,6 +436,14 @@ class PersonalRequest(TimeStampedModel): def __str__(self): return self.name + def save(self, *args, **kwargs): + if self.city: + self.county = self.city.county.upper() + else: + self.county = "" + + super().save(*args, **kwargs) + class RegisterNGORequest(TimeStampedModel): name = models.CharField(_("Name"), max_length=254) @@ -437,7 +465,8 @@ class RegisterNGORequest(TimeStampedModel): contact_phone = models.CharField(_("Contact person's phone"), max_length=15) has_netopia_contract = models.BooleanField(_("Has contract with Netopia"), default=False) address = models.CharField(_("Address"), max_length=400) - city = models.CharField(_("City"), max_length=100) + city = models.ForeignKey("City", on_delete=models.PROTECT, null=True, verbose_name=_("City")) + city_old = models.CharField(_("City (legacy)"), max_length=100) county = models.CharField(_("County"), choices=COUNTY.to_choices(), max_length=50) social_link = models.CharField(_("Link to website or Facebook"), max_length=512, null=True, blank=True) @@ -461,14 +490,17 @@ class Meta: def yes(self): return self.votes.filter(vote="YES").count() + yes.short_description = _("Yes") def no(self): return self.votes.filter(vote="NO").count() + no.short_description = _("No") def abstention(self): return self.votes.filter(vote="ABSTENTION").count() + abstention.short_description = _("Abstention") def create_ngo_owner(self, request, ngo_group): @@ -523,6 +555,14 @@ def activate(self, request, ngo_group=None): self.active = True self.save() + def save(self, *args, **kwargs): + if self.city: + self.county = self.city.county.upper() + else: + self.county = "" + + super().save(*args, **kwargs) + def __str__(self): return self.name diff --git a/ro_help/hub/templates/base.html b/ro_help/hub/templates/base.html index ab9939e7..439789c3 100644 --- a/ro_help/hub/templates/base.html +++ b/ro_help/hub/templates/base.html @@ -1,6 +1,4 @@ -{% load static %} -{% load meta_tags %} - +{% load static meta_tags %} @@ -8,18 +6,23 @@ {% block meta_tags %}{% with title="Luptă împotriva pandemiei de Covid-19." %}{% meta_tags %}{% endwith %}{% endblock %} +