diff --git a/docker-compose.yml b/docker-compose.yml index c306932..f4fcae1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.9' - services: db: image: postgis/postgis:13-3.3 diff --git a/neighbourhood/admin.py b/neighbourhood/admin.py index c365573..1760331 100644 --- a/neighbourhood/admin.py +++ b/neighbourhood/admin.py @@ -4,10 +4,12 @@ from django.contrib.auth.forms import ReadOnlyPasswordHashField from django.contrib.gis.admin import OSMGeoAdmin from django.core.exceptions import ValidationError +from django.forms import ModelForm from django.urls import reverse from django.utils.html import format_html, mark_safe -from .models import Team, Token, User +from .forms import GeoJsonUploadFormMixin +from .models import Challenge, Team, Token, User @admin.register(Token) @@ -21,8 +23,15 @@ class TeamMembershipInline(admin.TabularInline): readonly_fields = ["created"] +class TeamAdminForm(GeoJsonUploadFormMixin, ModelForm): + class Meta: + model = Team + fields = "__all__" # This includes all fields from the model + + @admin.register(Team) class TeamAdmin(OSMGeoAdmin): + form = TeamAdminForm list_display = ( "name", "base_pc", @@ -37,6 +46,13 @@ class TeamAdmin(OSMGeoAdmin): TeamMembershipInline, ] + def save_model(self, request, obj, form, change): + if form.cleaned_data.get("geojson_file"): + obj.boundary = form.cleaned_data[ + "geojson_file" + ] # Set the geometry from the uploaded GeoJSON + super().save_model(request, obj, form, change) + @admin.display(description="Members") def members_count(self, obj): return obj.members.count() @@ -46,6 +62,14 @@ def confirmed_members_count(self, obj): return obj.confirmed_members.count() +@admin.register(Challenge) +class ChallengeAdmin(admin.ModelAdmin): + list_display = ("name", "description", "order", "is_active", "is_public") + list_editable = ("order", "is_active", "is_public") + list_filter = ["is_active", "is_public", "has_rich_description"] + ordering = ["order"] + + class UserCreationForm(forms.ModelForm): password1 = forms.CharField(label="Password", widget=forms.PasswordInput) password2 = forms.CharField( diff --git a/neighbourhood/fixtures/marple.geojson b/neighbourhood/fixtures/marple.geojson new file mode 100644 index 0000000..bfdcaab --- /dev/null +++ b/neighbourhood/fixtures/marple.geojson @@ -0,0 +1,85 @@ +{ + "type": "Feature", + "properties": { + "name": "ENWL Marple tender area", + "centroid_postcode": "SK6 6LS", + "centroid_x": -2.066591374100896, + "centroid_y": 53.39961996874385 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-2.039118812158511, 53.39607300484717], + [-2.042842814812317, 53.39448694473424], + [-2.043153963148892, 53.39435496974106], + [-2.043761038598267, 53.39377300743112], + [-2.046757946071736, 53.39383891960464], + [-2.050842111782887, 53.39393000347705], + [-2.053311848440355, 53.39593310140093], + [-2.055799188015477, 53.396391994215016], + [-2.05680004484894, 53.396876003684966], + [-2.059123055858409, 53.39622095299339], + [-2.06229915905537, 53.39467500577833], + [-2.06335976411161, 53.39466401864138], + [-2.06440097127707, 53.394654021332414], + [-2.06570214184269, 53.393256036753165], + [-2.06547001267402, 53.39309194361166], + [-2.065492081719879, 53.390727038474076], + [-2.066833809553655, 53.39054993624833], + [-2.067586976880684, 53.39045090599354], + [-2.069305992228256, 53.391776000859906], + [-2.070963038905869, 53.39158698299477], + [-2.073016963213321, 53.391353026010236], + [-2.073820787541126, 53.392889935164014], + [-2.075369794717434, 53.39307098259181], + [-2.079744806544592, 53.393582007788005], + [-2.081626840001368, 53.39261104497916], + [-2.0820009295697, 53.39267101126091], + [-2.087773068832873, 53.39360395350738], + [-2.089930786303418, 53.393384100691996], + [-2.094063936043281, 53.396428031762014], + [-2.090812026940374, 53.400239010298925], + [-2.089640188121193, 53.40084096323959], + [-2.087241984207192, 53.404106938829216], + [-2.085080126112944, 53.40456503197877], + [-2.081552854913455, 53.404136050089164], + [-2.081104876333191, 53.40418804034439], + [-2.074833068962502, 53.404920041956295], + [-2.073134766096288, 53.406598990510524], + [-2.072818081077206, 53.406658059721565], + [-2.071054134738526, 53.4074920948995], + [-2.070827776288178, 53.40760009242962], + [-2.065489212442922, 53.40818300218019], + [-2.062966068471988, 53.408459054660995], + [-2.061752148194655, 53.40878903149416], + [-2.059870870972401, 53.40760296621254], + [-2.057656939619407, 53.40694897515642], + [-2.055820926222149, 53.40640604434848], + [-2.05557695740054, 53.406185939233396], + [-2.055993040137756, 53.40483494469654], + [-2.051116151063674, 53.402714945318706], + [-2.050852048840518, 53.40260000516593], + [-2.050575130365015, 53.401573989968206], + [-2.047693948274075, 53.39978106586205], + [-2.047631188850132, 53.399741900936355], + [-2.046798794412879, 53.39978798364892], + [-2.046278006232489, 53.39968302162833], + [-2.043686098226991, 53.399851999828556], + [-2.042906039561796, 53.39990199109662], + [-2.039514844632444, 53.396473044408225], + [-2.039118812158511, 53.39607300484717] + ], + [ + [-2.065494183111713, 53.39823698499189], + [-2.066366779330699, 53.399119986912446], + [-2.067535096349404, 53.399247955598184], + [-2.071064009447584, 53.39963600337904], + [-2.072254815444114, 53.39705197360281], + [-2.068512065186642, 53.39682497953908], + [-2.066200109513213, 53.396685887375654], + [-2.065494183111713, 53.39823698499189] + ] + ] + } +} diff --git a/neighbourhood/fixtures/memberships.json b/neighbourhood/fixtures/memberships.json index c49c858..16d5b1c 100644 --- a/neighbourhood/fixtures/memberships.json +++ b/neighbourhood/fixtures/memberships.json @@ -29,6 +29,16 @@ "email_confirmed": true } }, + { + "model": "neighbourhood.user", + "pk": 4, + "fields": { + "email": "scottishparliament-admin@example.org", + "full_name": "Scottish Parliament Admin", + "is_active": true, + "email_confirmed": true + } + }, { "model": "neighbourhood.membership", "pk": 1, @@ -65,5 +75,18 @@ "last_updated": "2023-01-01T14:23:00Z", "date_joined": "2023-01-01" } + }, + { + "model": "neighbourhood.membership", + "pk": 4, + "fields": { + "team_id": 1, + "user_id": 4, + "confirmed": true, + "is_admin": true, + "created": "2023-01-01T14:23:00Z", + "last_updated": "2023-01-01T14:23:00Z", + "date_joined": "2023-01-01" + } } ] diff --git a/neighbourhood/fixtures/teams.json b/neighbourhood/fixtures/teams.json index 8b353a9..8156c00 100644 --- a/neighbourhood/fixtures/teams.json +++ b/neighbourhood/fixtures/teams.json @@ -1,4 +1,31 @@ [ + { + "model": "neighbourhood.challenge", + "pk": 1, + "fields": { + "name": "Recruit first team member", + "description": "Recruit your first team member", + "template": "neighbourhood/challenges/_default.html", + "created": "2023-06-22T14:23:00Z", + "last_updated": "2023-06-22T14:23:00Z", + "is_active": true, + "order": 1 + } + }, + { + "model": "neighbourhood.challenge", + "pk": 2, + "fields": { + "name": "Recruit second team member", + "description": "
Recruit your second team member
", + "has_rich_description": true, + "template": "neighbourhood/challenges/_default.html", + "created": "2023-06-22T14:23:00Z", + "last_updated": "2023-06-22T14:23:00Z", + "is_active": true, + "order": 2 + } + }, { "model": "neighbourhood.team", "pk": 1, @@ -24,6 +51,7 @@ "areas": [ 1 ], "created": "2023-06-22T14:23:00Z", "last_updated": "2023-06-22T14:23:00Z", + "challenge_id": 1, "confirmed": true } }, @@ -55,6 +83,20 @@ "confirmed": false } }, + { + "model": "neighbourhood.team", + "pk": 5, + "fields": { + "name": "southwark 1", + "base_pc": "se17 3he", + "centroid": "SRID=4326;POINT (-0.096961 51.484853)", + "slug": "southwark 1", + "areas": [ 2 ], + "created": "2023-06-22T14:23:00Z", + "last_updated": "2023-06-22T14:23:00Z", + "confirmed": true + } + }, { "model": "neighbourhood.area", "pk": 1, @@ -66,5 +108,17 @@ "last_updated": "2023-06-22T14:23:00Z", "mapit_id": 2651 } + }, + { + "model": "neighbourhood.area", + "pk": 2, + "fields": { + "code": "E09000028", + "name": "Southwark Borough Council", + "area_type": "LBO", + "created": "2023-06-22T14:23:00Z", + "last_updated": "2023-06-22T14:23:00Z", + "mapit_id": 2491 + } } ] diff --git a/neighbourhood/forms.py b/neighbourhood/forms.py index 2d90d66..ede4418 100644 --- a/neighbourhood/forms.py +++ b/neighbourhood/forms.py @@ -1,3 +1,6 @@ +import json + +from django.contrib.gis.geos import GEOSGeometry from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ValidationError from django.core.mail import EmailMessage @@ -5,6 +8,7 @@ BaseModelFormSet, CharField, EmailField, + FileField, Form, ModelForm, modelformset_factory, @@ -171,3 +175,25 @@ def clean(self): raise ValidationError(data["error"]) self.postcode_data = data + + +class GeoJsonUploadFormMixin(ModelForm): + geojson_file = FileField(required=False, label="Upload GeoJSON") + + def clean_geojson_file(self): + file = self.cleaned_data.get("geojson_file") + if file: + try: + geojson_data = json.load(file) + if geojson_data["type"] == "FeatureCollection": + feature = geojson_data["features"][0] + elif geojson_data["type"] == "Feature": + feature = geojson_data + else: + raise ValidationError( + "GeoJSON file must contain a single Feature or FeatureCollection." + ) + return GEOSGeometry(json.dumps(feature["geometry"])) + except (KeyError, TypeError, json.JSONDecodeError): + raise ValidationError("Invalid GeoJSON file format.") + return None diff --git a/neighbourhood/management/commands/load_marple_data.py b/neighbourhood/management/commands/load_marple_data.py new file mode 100644 index 0000000..3ff9f8c --- /dev/null +++ b/neighbourhood/management/commands/load_marple_data.py @@ -0,0 +1,118 @@ +import json +import os + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.gis.geos import GEOSGeometry, Point +from django.core.management.base import BaseCommand +from django.template.defaultfilters import pluralize +from django.utils.text import slugify + +from neighbourhood.models import Membership, Team +from neighbourhood.services.teams import add_areas_to_team +from neighbourhood.utils import get_postcode_data + +User = get_user_model() + + +def fake_email(name): + return "{}@localhost".format(slugify(name)) + + +class Command(BaseCommand): + help = "Load a Marple user and team into the site" + + def add_arguments(self, parser): + parser.add_argument( + "-d", + "--delete", + action="store_true", + help="Delete all teams and all non-staff users before loading fake data", + ) + + parser.add_argument( + "-D", + "--delete-only", + action="store_true", + help="Delete all teams and all non-staff users, and then stop, without loading any new data", + ) + + def handle(self, verbosity, delete=False, delete_only=False, *args, **options): + self.verbosity = verbosity + + if delete or delete_only: + teams = Team.objects.all() + users = User.objects.filter(is_staff=False) + self.log( + "deleting {} {} and {} non-staff {}".format( + teams.count(), + pluralize(teams.count(), "team,teams"), + users.count(), + pluralize(users.count(), "user,users"), + ) + ) + + teams.delete() + users.delete() + + if delete_only: + exit() + + geojson_file_path = os.path.join( + settings.BASE_DIR, "neighbourhood", "fixtures", "marple.geojson" + ) + with open(geojson_file_path, "r") as file: + try: + geojson_data = json.load(file) + except json.JSONDecodeError as e: + self.stdout.write(self.style.ERROR(f"Error decoding JSON: {e}")) + return + + base_pc = geojson_data["properties"]["centroid_postcode"] + postcode_data = get_postcode_data(base_pc) + + team_obj = { + "name": "Powershaper Flex Marple", + "address_1": "", + "address_2": "Marple", + "address_3": "Greater Manchester", + "centroid": Point( + geojson_data["properties"]["centroid_x"], + geojson_data["properties"]["centroid_y"], + srid=4326, + ), + "boundary": GEOSGeometry(json.dumps(geojson_data["geometry"])), + "status": "recruiting for a Spring 2025 flexibility tender", + "confirmed": True, + } + + self.log("creating team {}".format(team_obj["name"])) + team, _ = Team.objects.update_or_create(base_pc=base_pc, defaults=team_obj) + + add_areas_to_team(team, postcode_data["areas"]) + + user_obj = { + "full_name": "Miss Marple", + "email_confirmed": True, + } + + self.log("creating user {}".format(user_obj["full_name"])) + user, _ = User.objects.update_or_create( + email=fake_email(user_obj["full_name"]), defaults=user_obj + ) + + team.creator = user + team.save() + + Membership.objects.update_or_create( + team=team, + user=user, + defaults={ + "confirmed": True, + "is_admin": True, + }, + ) + + def log(self, message, *args): + if self.verbosity != 0: + self.stdout.write(message.format(*args)) diff --git a/neighbourhood/migrations/0019_challenges.py b/neighbourhood/migrations/0019_challenges.py new file mode 100644 index 0000000..2c3ad6b --- /dev/null +++ b/neighbourhood/migrations/0019_challenges.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.15 on 2024-08-14 10:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("neighbourhood", "0018_add_last_updated_and_created"), + ] + + operations = [ + migrations.CreateModel( + name="Challenge", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=300)), + ("description", models.TextField()), + ("template", models.CharField(max_length=300)), + ("is_active", models.BooleanField(default=True)), + ("order", models.IntegerField()), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/neighbourhood/migrations/0020_team_challenge.py b/neighbourhood/migrations/0020_team_challenge.py new file mode 100644 index 0000000..b7809bf --- /dev/null +++ b/neighbourhood/migrations/0020_team_challenge.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2024-08-14 10:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("neighbourhood", "0019_challenges"), + ] + + operations = [ + migrations.AddField( + model_name="team", + name="challenge", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="neighbourhood.challenge", + ), + ), + ] diff --git a/neighbourhood/migrations/0021_challenge_rich_description.py b/neighbourhood/migrations/0021_challenge_rich_description.py new file mode 100644 index 0000000..28265ea --- /dev/null +++ b/neighbourhood/migrations/0021_challenge_rich_description.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.15 on 2024-08-14 11:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("neighbourhood", "0020_team_challenge"), + ] + + operations = [ + migrations.AddField( + model_name="challenge", + name="has_rich_description", + field=models.BooleanField( + default=False, help_text="True if description is raw html" + ), + ), + ] diff --git a/neighbourhood/migrations/0022_public_challenge_fields.py b/neighbourhood/migrations/0022_public_challenge_fields.py new file mode 100644 index 0000000..7aed624 --- /dev/null +++ b/neighbourhood/migrations/0022_public_challenge_fields.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.15 on 2024-08-14 14:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("neighbourhood", "0021_challenge_rich_description"), + ] + + operations = [ + migrations.AddField( + model_name="challenge", + name="is_public", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="challenge", + name="short_description", + field=models.CharField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/neighbourhood/migrations/0023_alter_challenge_description_and_more.py b/neighbourhood/migrations/0023_alter_challenge_description_and_more.py new file mode 100644 index 0000000..5850ace --- /dev/null +++ b/neighbourhood/migrations/0023_alter_challenge_description_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 4.1 on 2024-09-25 14:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("neighbourhood", "0022_public_challenge_fields"), + ] + + operations = [ + migrations.AlterField( + model_name="challenge", + name="description", + field=models.TextField( + help_text="Detailed text content (plain text or HTML) shown to signed-in team members" + ), + ), + migrations.AlterField( + model_name="challenge", + name="has_rich_description", + field=models.BooleanField( + default=False, help_text="True if description is raw HTML" + ), + ), + migrations.AlterField( + model_name="challenge", + name="is_active", + field=models.BooleanField( + default=True, help_text="Inactive challenges are not displayed anywhere" + ), + ), + migrations.AlterField( + model_name="challenge", + name="is_public", + field=models.BooleanField( + default=True, + help_text="Private challenges are shown only to signed-in team members", + ), + ), + migrations.AlterField( + model_name="challenge", + name="short_description", + field=models.CharField( + blank=True, + help_text="A one line summary of the challenge, suitable for public view", + max_length=1000, + null=True, + ), + ), + migrations.AlterField( + model_name="challenge", + name="template", + field=models.CharField( + help_text="Default: neighbourhood/challenges/_default.html", + max_length=300, + ), + ), + ] diff --git a/neighbourhood/migrations/0024_team_boundary.py b/neighbourhood/migrations/0024_team_boundary.py new file mode 100644 index 0000000..e701148 --- /dev/null +++ b/neighbourhood/migrations/0024_team_boundary.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1 on 2024-09-25 18:05 + +import django.contrib.gis.db.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("neighbourhood", "0023_alter_challenge_description_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="team", + name="boundary", + field=django.contrib.gis.db.models.fields.PolygonField( + blank=True, null=True, srid=4326 + ), + ), + ] diff --git a/neighbourhood/migrations/0025_team_description_team_has_rich_description.py b/neighbourhood/migrations/0025_team_description_team_has_rich_description.py new file mode 100644 index 0000000..dd81493 --- /dev/null +++ b/neighbourhood/migrations/0025_team_description_team_has_rich_description.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1 on 2024-10-04 13:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("neighbourhood", "0024_team_boundary"), + ] + + operations = [ + migrations.AddField( + model_name="team", + name="description", + field=models.TextField( + blank=True, + help_text="Detailed text content (plain text or HTML) shown on the public team page", + ), + ), + migrations.AddField( + model_name="team", + name="has_rich_description", + field=models.BooleanField( + default=False, help_text="True if description is raw HTML" + ), + ), + ] diff --git a/neighbourhood/models.py b/neighbourhood/models.py index 3153865..205feca 100644 --- a/neighbourhood/models.py +++ b/neighbourhood/models.py @@ -1,6 +1,7 @@ -from hashlib import sha256 +import json from random import randrange +from django import template from django.conf import settings from django.contrib.auth.models import ( AbstractBaseUser, @@ -11,6 +12,7 @@ from django.contrib.gis.db import models from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.geos import Point +from django.core.mail import mail_admins from django.db.models.signals import pre_save from django.dispatch import receiver from django.utils import timezone @@ -75,11 +77,6 @@ class User(AbstractBaseUser, PermissionsMixin): objects = MyUserManager() - @property - def avatar_url(self): - colours = ["fcbf49", "eae2b7", "198754", "d62828", "ccc7ab"] - return f"https://source.boringavatars.com/beam/120/{sha256(self.email.encode('utf-8')).hexdigest()}?square&colors={','.join(colours)}" - class Area(models.Model): AREA_TYPES = { @@ -136,10 +133,56 @@ def __str__(self): return "{} ({} {})".format(self.name, self.area_type, self.mapit_id) +class Challenge(models.Model): + name = models.CharField(max_length=300) + short_description = models.CharField( + max_length=1000, + null=True, + blank=True, + help_text="A one line summary of the challenge, suitable for public view", + ) + description = models.TextField( + help_text="Detailed text content (plain text or HTML) shown to signed-in team members" + ) + template = models.CharField( + max_length=300, help_text=f"Default: {settings.DEFAULT_CHALLENGE_TEMPLATE}" + ) + is_active = models.BooleanField( + default=True, help_text="Inactive challenges are not displayed anywhere" + ) + is_public = models.BooleanField( + default=True, + help_text="Private challenges are shown only to signed-in team members", + ) + order = models.IntegerField() + + has_rich_description = models.BooleanField( + default=False, help_text="True if description is raw HTML" + ) + + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + def get_template_safe(self): + try: + template.loader.get_template(self.template) + return self.template + except template.TemplateDoesNotExist: + mail_admins( + "Bad challenge template", + f"Challenge {self.name} template not found: {self.template}", + ) + return settings.DEFAULT_CHALLENGE_TEMPLATE + + def __str__(self): + return self.name + + class Team(models.Model): name = models.CharField(max_length=100) base_pc = models.CharField(max_length=10) centroid = models.PointField() + boundary = models.PolygonField(blank=True, null=True) slug = models.CharField( max_length=100, blank=True, null=True, default="", unique=True ) @@ -156,6 +199,17 @@ class Team(models.Model): confirmed = models.BooleanField(default=False) status = models.CharField(max_length=300, blank=True, null=True) + description = models.TextField( + blank=True, + help_text="Detailed text content (plain text or HTML) shown on the public team page", + ) + has_rich_description = models.BooleanField( + default=False, help_text="True if description is raw HTML" + ) + + challenge = models.ForeignKey( + Challenge, on_delete=models.SET_NULL, blank=True, null=True + ) created = models.DateTimeField(auto_now_add=True) last_updated = models.DateTimeField(auto_now=True) @@ -183,6 +237,43 @@ def vicinity(self): def admins(self): return User.objects.filter(team=self, membership__is_admin=True) + def available_challenges(self, public_only=False): + # if there's no challenge set then use the default ones + if self.challenge is None: + return [] + + challenges = Challenge.objects.filter(is_active=True).order_by("order") + if public_only: + challenges = challenges.filter(is_public=True) + + current_place = 0 + if self.challenge: + current_place = self.challenge.order + + challenge_details = [] + for challenge in challenges: + details = { + "challenge": challenge, + } + + if challenge.order < current_place: + details["done"] = True + elif challenge.order == current_place: + details["active"] = True + + challenge_details.append(details) + + return challenge_details + + @property + def boundary_geojson(self): + if self.boundary: + return json.dumps( + {"type": "Feature", "geometry": json.loads(self.boundary.geojson)} + ) + else: + return None + @classmethod def find_nearest_teams(self, latitude=None, longitude=None, distance=5): if latitude is None or longitude is None: diff --git a/neighbourhood/static/css/_buttons.scss b/neighbourhood/static/css/_buttons.scss index 6991d6f..201619c 100644 --- a/neighbourhood/static/css/_buttons.scss +++ b/neighbourhood/static/css/_buttons.scss @@ -1,3 +1,16 @@ .btn-xs { @include button-size(0.1rem, 0.4rem, $font-size-base * 0.75, $btn-border-radius-sm); } + +@keyframes success-ping { + 0% { + box-shadow: 0 0 0 0rem rgba($yellow, 0.5); + } + 100% { + box-shadow: 0 0 0 10rem rgba($yellow, 0); + } +} + +[data-copy-text][data-copied] { + animation: 500ms linear success-ping; +} diff --git a/neighbourhood/static/css/_leaflet.scss b/neighbourhood/static/css/_leaflet.scss new file mode 100644 index 0000000..bd1ad31 --- /dev/null +++ b/neighbourhood/static/css/_leaflet.scss @@ -0,0 +1,14 @@ +.current-location-marker { + stroke-width: 2px; + filter: drop-shadow(0 0 5px #{$blue}); + animation: ease-in-out 2s infinite alternate current-location-throb; +} + +@keyframes current-location-throb { + from { + stroke-width: 2px; + } + to { + stroke-width: 4px; + } +} diff --git a/neighbourhood/static/css/_site-content.scss b/neighbourhood/static/css/_site-content.scss index 4fd4e6f..f153c8d 100644 --- a/neighbourhood/static/css/_site-content.scss +++ b/neighbourhood/static/css/_site-content.scss @@ -1,3 +1,8 @@ .site-content { min-height: 60vh; } + +img { + max-width: 100%; + height: auto; +} diff --git a/neighbourhood/static/css/_variables.scss b/neighbourhood/static/css/_variables.scss index d48ee39..cab6c2b 100644 --- a/neighbourhood/static/css/_variables.scss +++ b/neighbourhood/static/css/_variables.scss @@ -20,8 +20,10 @@ $green: #198754 !default; $teal: #20c997 !default; $cyan: #0dcaf0 !default; -$color-facebook-blue: #3b5998; -$color-twitter-blue: #1da1f2; +$color-facebook-blue: #1877f2; +$color-twitter-black: #14171a; +$color-threads-black: #000; +$color-linkedin-blue: #0a66c2; $color-whatsapp-green: #25d366; $color-gmail-red: #ea4335; @@ -44,7 +46,9 @@ $theme-colors: ( "light": $light, "dark": $dark, "facebook": $color-facebook-blue, - "twitter": $color-twitter-blue, + "twitter": $color-twitter-black, + "threads": $color-threads-black, + "linkedin": $color-linkedin-blue, "whatsapp": $color-whatsapp-green, "gmail": $color-gmail-red, ); diff --git a/neighbourhood/static/css/main.scss b/neighbourhood/static/css/main.scss index 9205d7f..3c0ec89 100644 --- a/neighbourhood/static/css/main.scss +++ b/neighbourhood/static/css/main.scss @@ -26,7 +26,7 @@ // @import "../../../vendor/bootstrap/scss/button-group"; @import "../../../vendor/bootstrap/scss/nav"; @import "../../../vendor/bootstrap/scss/navbar"; -// @import "../../../vendor/bootstrap/scss/card"; +@import "../../../vendor/bootstrap/scss/card"; // @import "../../../vendor/bootstrap/scss/accordion"; // @import "../../../vendor/bootstrap/scss/breadcrumb"; // @import "../../../vendor/bootstrap/scss/pagination"; @@ -34,9 +34,9 @@ // @import "../../../vendor/bootstrap/scss/alert"; @import "../../../vendor/bootstrap/scss/progress"; @import "../../../vendor/bootstrap/scss/list-group"; -// @import "../../../vendor/bootstrap/scss/close"; +@import "../../../vendor/bootstrap/scss/close"; // @import "../../../vendor/bootstrap/scss/toasts"; -// @import "../../../vendor/bootstrap/scss/modal"; +@import "../../../vendor/bootstrap/scss/modal"; // @import "../../../vendor/bootstrap/scss/tooltip"; // @import "../../../vendor/bootstrap/scss/popover"; // @import "../../../vendor/bootstrap/scss/carousel"; @@ -51,3 +51,5 @@ @import "site-content"; @import "transitions"; @import "buttons"; + +@import "leaflet" diff --git a/neighbourhood/static/img/flickr-13975813631-energysmart-academy-CC0.jpg b/neighbourhood/static/img/flickr-13975813631-energysmart-academy-CC0.jpg new file mode 100644 index 0000000..201d79c Binary files /dev/null and b/neighbourhood/static/img/flickr-13975813631-energysmart-academy-CC0.jpg differ diff --git a/neighbourhood/static/img/flickr-28558546374-david-dodge-green-energy-futures-CC-BY-NC.jpg b/neighbourhood/static/img/flickr-28558546374-david-dodge-green-energy-futures-CC-BY-NC.jpg new file mode 100644 index 0000000..2050a3e Binary files /dev/null and b/neighbourhood/static/img/flickr-28558546374-david-dodge-green-energy-futures-CC-BY-NC.jpg differ diff --git a/neighbourhood/static/img/flickr-28892760260-consumers-energy-CC-BY-NC.jpg b/neighbourhood/static/img/flickr-28892760260-consumers-energy-CC-BY-NC.jpg new file mode 100644 index 0000000..2ab6874 Binary files /dev/null and b/neighbourhood/static/img/flickr-28892760260-consumers-energy-CC-BY-NC.jpg differ diff --git a/neighbourhood/static/js/all.js b/neighbourhood/static/js/all.js index 428b003..92442ea 100644 --- a/neighbourhood/static/js/all.js +++ b/neighbourhood/static/js/all.js @@ -28,4 +28,32 @@ $(function(){ var $navLinks = $('.nav-link[aria-controls="' + id + '"]'); $navLinks.toggleClass('active'); }); + + $('#postcodeModal').each(function(){ + if ( $(this).find('.is-invalid').length ) { + var modal = bootstrap.Modal.getOrCreateInstance(this); + modal.show() + } + }).on('shown.bs.modal', function(){ + $(this).find('#postcode').focus(); + }); + + $('[data-copy-text]').on('click', function(e){ + e.stopPropagation(); + if (navigator.clipboard) { + var $el = $(this); + var $feedback = $el.find('[data-copy-feedback]'); + var copyText = $el.attr('data-copy-text'); + var successHTML = $el.attr('data-copy-success'); + var originalHTML = $feedback.html(); + navigator.clipboard.writeText(copyText).then(function(){ + $feedback.html(successHTML); + $el.attr('data-copied', true); + setTimeout(function(){ + $feedback.html(originalHTML); + $el.removeAttr('data-copied'); + }, 2000); + }); + } + }); }); diff --git a/neighbourhood/static/js/map.js b/neighbourhood/static/js/map.js index b651728..cdcff6e 100644 --- a/neighbourhood/static/js/map.js +++ b/neighbourhood/static/js/map.js @@ -1,49 +1,106 @@ import $ from '../jquery/jquery.esm.js' import L from '../leaflet/leaflet-1.9.3.esm.js' +L.Icon.Default.imagePath = "/static/leaflet/images/"; + +// https://github.com/ebrelsford/Leaflet.snogylop +(function(){var isFlat=L.LineUtil.isFlat?L.LineUtil.isFlat:L.LineUtil._flat;function defineSnogylop(L){var worldLatlngs=[L.latLng([90,180]),L.latLng([90,-180]),L.latLng([-90,-180]),L.latLng([-90,180])];if(L.version<'1.0.0'){L.extend(L.Polygon.prototype,{initialize:function(latlngs,options){worldLatlngs=(options.worldLatLngs?options.worldLatLngs:worldLatlngs);if(options&&options.invert&&!options.invertMultiPolygon){var newLatlngs=[];newLatlngs.push(worldLatlngs);newLatlngs.push(latlngs[0]);latlngs=newLatlngs}L.Polyline.prototype.initialize.call(this,latlngs,options);this._initWithHoles(latlngs)},getBounds:function(){if(this.options.invert){return new L.LatLngBounds(this._holes)}return new L.LatLngBounds(this.getLatLngs())}});L.extend(L.MultiPolygon.prototype,{initialize:function(latlngs,options){worldLatlngs=(options.worldLatLngs?options.worldLatLngs:worldLatlngs);this._layers={};this._options=options;if(options.invert){options.invertMultiPolygon=true;var newLatlngs=[];newLatlngs.push(worldLatlngs);for(var l in latlngs){newLatlngs.push(latlngs[l][0])}latlngs=[newLatlngs]}this.setLatLngs(latlngs)}})}else{var OriginalPolygon={toGeoJSON:L.Polygon.prototype.toGeoJSON};L.extend(L.Polygon.prototype,{_setLatLngs:function(latlngs){this._originalLatLngs=latlngs;if(isFlat(this._originalLatLngs)){this._originalLatLngs=[this._originalLatLngs]}if(this.options.invert){worldLatlngs=(this.options.worldLatLngs?this.options.worldLatLngs:worldLatlngs);var newLatlngs=[];newLatlngs.push(worldLatlngs);for(var l in latlngs){newLatlngs.push(latlngs[l])}latlngs=[newLatlngs]}L.Polyline.prototype._setLatLngs.call(this,latlngs)},getBounds:function(){if(this._originalLatLngs){return new L.LatLngBounds(this._originalLatLngs)}return new L.LatLngBounds(this.getLatLngs())},getLatLngs:function(){return this._originalLatLngs},toGeoJSON:function(precision){if(!this.options.invert){return OriginalPolygon.toGeoJSON.call(this,precision)}var holes=!isFlat(this._originalLatLngs),multi=holes&&!isFlat(this._originalLatLngs[0]);var coords=L.GeoJSON.latLngsToCoords(this._originalLatLngs,multi?2:holes?1:0,true,precision);if(!holes){coords=[coords]}return L.GeoJSON.getFeature(this,{type:(multi?'Multi':'')+'Polygon',coordinates:coords})}})}}if(typeof define==='function'&&define.amd){define(['leaflet'],function(L){defineSnogylop(L)})}else{defineSnogylop(L)}})(); + $(function(){ - /* for some reason leaflet's icon path detection is failing for some icons - * so hard code it for now */ - L.Icon.Default.prototype.options["imagePath"] = "/static/leaflet/images/"; var map = new L.Map("leaflet"); map.attributionControl.setPrefix(''); - var osm = new L.TileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: 'Map © OpenStreetMap contributors', - maxZoom: 18 + var OpenStreetMap_HOT = L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors, HOT OSM' }); - map.addLayer(osm); - - var mapit = mapit || {}; - mapit.area_loaded = function(data) { - var area = new L.GeoJSON(data); - area.on('dblclick', function(e){ - var z = map.getZoom() + (e.originalEvent.shiftKey ? -1 : 1); - map.setZoomAround(e.containerPoint, z); + map.addLayer(OpenStreetMap_HOT); + + var features = L.featureGroup().addTo(map); + + if ( window.gss && window.mapit_id ) { + // Local authority area page + + function area_loaded(data) { + var area = new L.GeoJSON(data); + area.on('dblclick', function(e){ + var z = map.getZoom() + (e.originalEvent.shiftKey ? -1 : 1); + map.setZoomAround(e.containerPoint, z); + }); + features.addLayer(area); + map.fitBounds(features.getBounds()); + }; + + function teams_loaded(data) { + var area = new L.GeoJSON(data, { + onEachFeature: function (feature, layer) { + layer.bindPopup(''); + } + }); + features.addLayer(area); + } + + $.ajax({ + dataType: "json", + url: "/area/" + window.mapit_id + "/geometry", + success: area_loaded }); - mapit.areas.addLayer(area); - map.fitBounds(mapit.areas.getBounds()); - }; - - mapit.teams_loaded = function(data) { - var area = new L.GeoJSON(data, { - onEachFeature: function (feature, layer) { - layer.bindPopup(''); - } + + $.ajax({ + dataType: "json", + url: "/area/" + window.gss + "/teams", + success: teams_loaded }); - mapit.areas.addLayer(area); + } - mapit.areas = L.featureGroup().addTo(map); - $.ajax({ - dataType: "json", - url: "/area/" + mapit_id + "/geometry", - success: mapit.area_loaded - }); + if ( window.user_latlon ) { + var user_marker = new L.circleMarker( + window.user_latlon, + { + color: "#ffffff", + fillColor: "#0d6efd", + fillOpacity: 1, + className: "current-location-marker", + pane: "markerPane", + interactive: false + } + ).addTo(features); + map.setView(window.user_latlon, 14); + } else if ( window.team_latlon ) { + map.setView(window.team_latlon, 14); + } - $.ajax({ - dataType: "json", - url: "/area/" + gss + "/teams", - success: mapit.teams_loaded - }); + if ( window.team_boundary_geojson ) { + var team_boundary = L.geoJSON( + window.team_boundary_geojson, + { + invert: true, + renderer: L.svg({ padding: 1 }), + interactive: false, + style: { + color: "#FCBF49", + weight: 4, + fillColor: "#fff", + fillOpacity: 0.6 + } + } + ).addTo(features); + map.fitBounds(features.getBounds()); + } + // Reload map, if it is inside a bootstrap collapse element + // that has just been shown (as happens for logged-in team + // members switching to the public view of their team page). + $(document).on('shown.bs.collapse', function(e){ + if ( map && e.target.contains(map._container) ) { + map.invalidateSize(); + if ( team_boundary ) { + map.fitBounds(features.getBounds()); + } else if ( window.user_latlon ) { + map.setView(window.user_latlon, 14); + } else if ( window.team_latlon ) { + map.setView(window.team_latlon, 14); + } + } + }); }); diff --git a/neighbourhood/templates/neighbourhood/about.html b/neighbourhood/templates/neighbourhood/about.html index 7957a7a..18e7eb6 100644 --- a/neighbourhood/templates/neighbourhood/about.html +++ b/neighbourhood/templates/neighbourhood/about.html @@ -1,10 +1,65 @@ {% extends "neighbourhood/base.html" %} +{% load static %} + {% block content %} -Right now, we’re piloting Neighbourhood Warmth in a few specific areas around the UK, with local partners like Carbon Co-op in Greater Manchester.
+But, eventually, our aim is that Neighbourhood Warmth will be able to support “teams” of neighbours through “challenges” including:
+ +We’ll walk you through the steps of getting a Whole House Retrofit Assessment, the most comprehensive way to find out what’s right for you and your home.
+There’s no need to suffer in a cold, leaky home. Draft proofing is a quick, easy way to improve your home and make new energy saving friends in your neighbourhood.
+Homes with storage heaters, solar panels, batteries, or electric vehicles are ideal candidates for Energy Flexibility schemes, which can save both money and the environment.
+Neighbourhood Warmth is run by mySociety, the charity behind websites like FixMyStreet, TheyWorkForYou, and WhatDoTheyKnow.
+Our free online services exist to put more power into more people’s hands, for a fairer, healthier society. Enabling more community-led home energy action is a big part of that.
+But we’re also massively grateful to the organisations who have helped us design and test Neighbourhood Warmth over the last few years, including Dark Matter Labs (with whom we ran the first alpha explorations for the site in 2023) and Carbon Co-op (with whom we’re running a pilot around energy flexibility in Marple, Greater Manchester).
+Our work on Neighbourhood Warmth so far has been funded by Quadrature Climate Foundation, the National Lottery Community Fund, and Aurora Trust.
+ +If you need to change these details, please contact us.
+You can also change your password.
+If you no longer need your account, you can delete it. This cannot be undone.
+It takes two to tango! Remember, team members should live in the same type of home. But don’t worry if you’re not sure when inviting someone as we can check that when they sign up.
+ +Use the share buttons below to inspire more community-based action on home energy!
+ +Or print out, display, and share our exclusive designer posters and flyers:
+ ++ Download goodies +
+ +Read more about our curated challenges, and then find teams already working on them near you:
- -This is the best, most comprehensive way to find out what’s right for you and your home.
- Read more… -A quick, easy way to improve your home and make new energy saving friends in your neighbourhood.
- Read more… -Turning down your boiler just a few degrees could save you almost £10 a month.
- Read more… -You told us your postcode is {{ request.session.user_postcode }}
+ Forget me +