Compare commits
2 Commits
0b2050443b
...
d6ca058864
| Author | SHA1 | Date | |
|---|---|---|---|
| d6ca058864 | |||
| 8c9ad49387 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -5,6 +5,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), version
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.7.0] — 2026-04-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Home District with 6-month cooldown**
|
||||||
|
- `district_changed_at` DateTimeField on User model (migration `0013_user_district_changed_at`) — nullable, no backfill; NULL means "eligible to change immediately"
|
||||||
|
- `VALID_DISTRICTS` constant (14 Kerala districts) in `accounts/models.py` for server-side validation
|
||||||
|
- `WebRegisterForm` now accepts optional `district` field; stamps `district_changed_at` on valid selection during signup
|
||||||
|
- `UpdateProfileView` enforces 183-day (~6 months) cooldown — rejects district changes within the window with a human-readable "Next change: {date}" error
|
||||||
|
- `district_changed_at` included in all relevant API responses: `LoginView`, `WebRegisterView`, `StatusView`, `UpdateProfileView`
|
||||||
|
- `StatusView` now also returns `district` field (was previously missing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.6.2] — 2026-04-03
|
## [1.6.2] — 2026-04-03
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
16
accounts/migrations/0013_user_district_changed_at.py
Normal file
16
accounts/migrations/0013_user_district_changed_at.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0012_user_eventify_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='district_changed_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -24,6 +24,12 @@ ROLE_CHOICES = (
|
|||||||
('partner_customer', 'Partner Customer'),
|
('partner_customer', 'Partner Customer'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
VALID_DISTRICTS = [
|
||||||
|
"Thiruvananthapuram", "Kollam", "Pathanamthitta", "Alappuzha", "Kottayam",
|
||||||
|
"Idukki", "Ernakulam", "Thrissur", "Palakkad", "Malappuram",
|
||||||
|
"Kozhikode", "Wayanad", "Kannur", "Kasaragod",
|
||||||
|
]
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
eventify_id = models.CharField(
|
eventify_id = models.CharField(
|
||||||
max_length=12,
|
max_length=12,
|
||||||
@@ -49,6 +55,7 @@ class User(AbstractUser):
|
|||||||
state = models.CharField(max_length=100, blank=True, null=True)
|
state = models.CharField(max_length=100, blank=True, null=True)
|
||||||
country = models.CharField(max_length=100, blank=True, null=True)
|
country = models.CharField(max_length=100, blank=True, null=True)
|
||||||
place = models.CharField(max_length=200, blank=True, null=True)
|
place = models.CharField(max_length=200, blank=True, null=True)
|
||||||
|
district_changed_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
# Location fields
|
# Location fields
|
||||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
|
latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class WebRegisterForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ['first_name', 'last_name', 'email', 'phone_number', 'password', 'confirm_password']
|
fields = ['first_name', 'last_name', 'email', 'phone_number', 'password', 'confirm_password', 'district']
|
||||||
|
|
||||||
def clean_email(self):
|
def clean_email(self):
|
||||||
email = self.cleaned_data.get('email')
|
email = self.cleaned_data.get('email')
|
||||||
@@ -76,6 +76,12 @@ class WebRegisterForm(forms.ModelForm):
|
|||||||
# Mark as a customer / end-user
|
# Mark as a customer / end-user
|
||||||
user.is_customer = True
|
user.is_customer = True
|
||||||
user.role = 'customer'
|
user.role = 'customer'
|
||||||
|
from django.utils import timezone
|
||||||
|
from accounts.models import VALID_DISTRICTS
|
||||||
|
if user.district and user.district in VALID_DISTRICTS:
|
||||||
|
user.district_changed_at = timezone.now()
|
||||||
|
elif user.district:
|
||||||
|
user.district = None # reject invalid district silently
|
||||||
if commit:
|
if commit:
|
||||||
user.save()
|
user.save()
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -13,9 +13,83 @@ from datetime import datetime, timedelta
|
|||||||
import calendar
|
import calendar
|
||||||
import math
|
import math
|
||||||
from mobile_api.utils import validate_token_and_get_user
|
from mobile_api.utils import validate_token_and_get_user
|
||||||
|
from accounts.models import User
|
||||||
from eventify_logger.services import log
|
from eventify_logger.services import log
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_contributor(identifier):
|
||||||
|
"""Resolve an eventifyId or email to a contributor dict. Returns None on miss."""
|
||||||
|
if not identifier:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
user = User.objects.filter(
|
||||||
|
Q(eventify_id=identifier) | Q(email=identifier)
|
||||||
|
).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Count events this user contributed
|
||||||
|
events_count = Event.objects.filter(
|
||||||
|
Q(contributed_by=user.eventify_id) | Q(contributed_by=user.email)
|
||||||
|
).filter(
|
||||||
|
event_status__in=['published', 'live', 'completed']
|
||||||
|
).count()
|
||||||
|
|
||||||
|
full_name = user.get_full_name() or user.username or ''
|
||||||
|
avatar = ''
|
||||||
|
if user.profile_picture and hasattr(user.profile_picture, 'url'):
|
||||||
|
try:
|
||||||
|
avatar = user.profile_picture.url
|
||||||
|
except Exception:
|
||||||
|
avatar = ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': full_name,
|
||||||
|
'email': user.email,
|
||||||
|
'eventify_id': user.eventify_id or '',
|
||||||
|
'avatar': avatar,
|
||||||
|
'member_since': user.date_joined.strftime('%b %Y') if user.date_joined else '',
|
||||||
|
'events_contributed': events_count,
|
||||||
|
'location': ', '.join(filter(None, [user.place or '', user.district or '', user.state or ''])),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_event_for_contributor(event):
|
||||||
|
"""Lightweight event serializer for contributor profile listings."""
|
||||||
|
primary_img = ''
|
||||||
|
try:
|
||||||
|
img = EventImages.objects.filter(event=event, is_primary=True).first()
|
||||||
|
if not img:
|
||||||
|
img = EventImages.objects.filter(event=event).first()
|
||||||
|
if img and img.event_image:
|
||||||
|
primary_img = img.event_image.url
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': event.id,
|
||||||
|
'name': event.name or event.title or '',
|
||||||
|
'title': event.title or event.name or '',
|
||||||
|
'start_date': event.start_date.isoformat() if event.start_date else '',
|
||||||
|
'end_date': event.end_date.isoformat() if event.end_date else '',
|
||||||
|
'start_time': str(event.start_time or ''),
|
||||||
|
'end_time': str(event.end_time or ''),
|
||||||
|
'image': primary_img,
|
||||||
|
'venue_name': event.venue_name or '',
|
||||||
|
'place': event.place or '',
|
||||||
|
'district': event.district or '',
|
||||||
|
'state': event.state or '',
|
||||||
|
'pincode': event.pincode or '',
|
||||||
|
'latitude': str(event.latitude) if event.latitude else '',
|
||||||
|
'longitude': str(event.longitude) if event.longitude else '',
|
||||||
|
'event_type': event.event_type_id,
|
||||||
|
'event_status': event.event_status or '',
|
||||||
|
'source': event.source or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _haversine_km(lat1, lon1, lat2, lon2):
|
def _haversine_km(lat1, lon1, lat2, lon2):
|
||||||
"""Great-circle distance between two points in km."""
|
"""Great-circle distance between two points in km."""
|
||||||
R = 6371.0
|
R = 6371.0
|
||||||
@@ -92,6 +166,7 @@ class EventListAPI(APIView):
|
|||||||
page = int(data.get("page", 1))
|
page = int(data.get("page", 1))
|
||||||
page_size = int(data.get("page_size", 50))
|
page_size = int(data.get("page_size", 50))
|
||||||
per_type = int(data.get("per_type", 0))
|
per_type = int(data.get("per_type", 0))
|
||||||
|
q = data.get("q", "").strip()
|
||||||
|
|
||||||
# New optional geo params
|
# New optional geo params
|
||||||
user_lat = data.get("latitude")
|
user_lat = data.get("latitude")
|
||||||
@@ -169,6 +244,10 @@ class EventListAPI(APIView):
|
|||||||
if pincode_qs.count() >= MIN_EVENTS_THRESHOLD:
|
if pincode_qs.count() >= MIN_EVENTS_THRESHOLD:
|
||||||
qs = pincode_qs
|
qs = pincode_qs
|
||||||
|
|
||||||
|
# Priority 3: Full-text search on title / description
|
||||||
|
if q:
|
||||||
|
qs = qs.filter(Q(title__icontains=q) | Q(description__icontains=q))
|
||||||
|
|
||||||
if per_type > 0 and page == 1:
|
if per_type > 0 and page == 1:
|
||||||
type_ids = list(qs.values_list('event_type_id', flat=True).distinct())
|
type_ids = list(qs.values_list('event_type_id', flat=True).distinct())
|
||||||
events_page = []
|
events_page = []
|
||||||
@@ -225,6 +304,14 @@ class EventDetailAPI(APIView):
|
|||||||
event_img['image'] = ei.event_image.url
|
event_img['image'] = ei.event_image.url
|
||||||
event_images_list.append(event_img)
|
event_images_list.append(event_img)
|
||||||
event_data["images"] = event_images_list
|
event_data["images"] = event_images_list
|
||||||
|
|
||||||
|
# Resolve contributor from contributed_by field
|
||||||
|
contributed_by = getattr(events, 'contributed_by', None)
|
||||||
|
if contributed_by:
|
||||||
|
contributor = _resolve_contributor(contributed_by)
|
||||||
|
if contributor:
|
||||||
|
event_data["contributor"] = contributor
|
||||||
|
|
||||||
return JsonResponse(event_data)
|
return JsonResponse(event_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log("error", "EventDetailAPI exception", request=request, logger_data={"error": str(e)})
|
log("error", "EventDetailAPI exception", request=request, logger_data={"error": str(e)})
|
||||||
@@ -542,3 +629,55 @@ class TopEventsAPI(APIView):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log("error", "TopEventsAPI exception", request=request, logger_data={"error": str(e)})
|
log("error", "TopEventsAPI exception", request=request, logger_data={"error": str(e)})
|
||||||
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})
|
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class ContributorProfileAPI(APIView):
|
||||||
|
"""
|
||||||
|
Public API to fetch a contributor's profile and their events.
|
||||||
|
POST /api/events/contributor-profile/
|
||||||
|
Body: { "contributor_id": "EVT-XXXXXXXX" } (or email)
|
||||||
|
"""
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body) if request.body else {}
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
contributor_id = data.get("contributor_id", "").strip()
|
||||||
|
if not contributor_id:
|
||||||
|
return JsonResponse(
|
||||||
|
{"status": "error", "message": "contributor_id is required"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve user
|
||||||
|
contributor = _resolve_contributor(contributor_id)
|
||||||
|
if not contributor:
|
||||||
|
return JsonResponse(
|
||||||
|
{"status": "error", "message": "Contributor not found"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch this contributor's events
|
||||||
|
user_identifiers = [v for v in [contributor['eventify_id'], contributor['email']] if v]
|
||||||
|
events_qs = Event.objects.filter(
|
||||||
|
contributed_by__in=user_identifiers,
|
||||||
|
event_status__in=['published', 'live', 'completed'],
|
||||||
|
).order_by('-start_date', '-created_date')
|
||||||
|
|
||||||
|
events_list = [_serialize_event_for_contributor(e) for e in events_qs]
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
"status": "success",
|
||||||
|
"contributor": contributor,
|
||||||
|
"events": events_list,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log("error", "ContributorProfileAPI exception", request=request, logger_data={"error": str(e)})
|
||||||
|
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ class WebRegisterView(View):
|
|||||||
'username': user.username,
|
'username': user.username,
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'phone_number': user.phone_number,
|
'phone_number': user.phone_number,
|
||||||
|
'district': user.district or '',
|
||||||
|
'district_changed_at': user.district_changed_at.isoformat() if user.district_changed_at else None,
|
||||||
|
'first_name': user.first_name,
|
||||||
|
'last_name': user.last_name,
|
||||||
|
'eventify_id': user.eventify_id or '',
|
||||||
}
|
}
|
||||||
return JsonResponse(response, status=201)
|
return JsonResponse(response, status=201)
|
||||||
log("warning", "Web registration failed", request=request, logger_data=dict(errors=form.errors))
|
log("warning", "Web registration failed", request=request, logger_data=dict(errors=form.errors))
|
||||||
@@ -93,6 +98,7 @@ class LoginView(View):
|
|||||||
'role': user.role,
|
'role': user.role,
|
||||||
'pincode': user.pincode,
|
'pincode': user.pincode,
|
||||||
'district': user.district,
|
'district': user.district,
|
||||||
|
'district_changed_at': user.district_changed_at.isoformat() if user.district_changed_at else None,
|
||||||
'state': user.state,
|
'state': user.state,
|
||||||
'country': user.country,
|
'country': user.country,
|
||||||
'place': user.place,
|
'place': user.place,
|
||||||
@@ -124,6 +130,8 @@ class StatusView(View):
|
|||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"eventify_id": user.eventify_id or '',
|
"eventify_id": user.eventify_id or '',
|
||||||
|
"district": user.district or '',
|
||||||
|
"district_changed_at": user.district_changed_at.isoformat() if user.district_changed_at else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -245,15 +253,33 @@ class UpdateProfileView(View):
|
|||||||
user.pincode = None
|
user.pincode = None
|
||||||
updated_fields.append('pincode')
|
updated_fields.append('pincode')
|
||||||
|
|
||||||
# Update district
|
# Update district (with 6-month cooldown)
|
||||||
if 'district' in json_data:
|
if 'district' in json_data:
|
||||||
district = json_data.get('district', '').strip()
|
from django.utils import timezone
|
||||||
if district:
|
from datetime import timedelta
|
||||||
user.district = district
|
from accounts.models import VALID_DISTRICTS
|
||||||
updated_fields.append('district')
|
|
||||||
elif district == '':
|
COOLDOWN = timedelta(days=183) # ~6 months
|
||||||
user.district = None
|
new_district = json_data.get('district', '').strip()
|
||||||
updated_fields.append('district')
|
|
||||||
|
if new_district and new_district not in VALID_DISTRICTS:
|
||||||
|
errors['district'] = 'Invalid district.'
|
||||||
|
elif new_district and new_district != (user.district or ''):
|
||||||
|
if user.district_changed_at and timezone.now() < user.district_changed_at + COOLDOWN:
|
||||||
|
next_date = (user.district_changed_at + COOLDOWN).strftime('%d %b %Y')
|
||||||
|
errors['district'] = f'District can only be changed once every 6 months. Next change: {next_date}.'
|
||||||
|
else:
|
||||||
|
user.district = new_district
|
||||||
|
user.district_changed_at = timezone.now()
|
||||||
|
updated_fields.append('district')
|
||||||
|
elif new_district == '' and user.district:
|
||||||
|
if user.district_changed_at and timezone.now() < user.district_changed_at + COOLDOWN:
|
||||||
|
next_date = (user.district_changed_at + COOLDOWN).strftime('%d %b %Y')
|
||||||
|
errors['district'] = f'District can only be changed once every 6 months. Next change: {next_date}.'
|
||||||
|
else:
|
||||||
|
user.district = None
|
||||||
|
user.district_changed_at = timezone.now()
|
||||||
|
updated_fields.append('district')
|
||||||
|
|
||||||
# Update state
|
# Update state
|
||||||
if 'state' in json_data:
|
if 'state' in json_data:
|
||||||
@@ -318,6 +344,7 @@ class UpdateProfileView(View):
|
|||||||
'phone_number': user.phone_number,
|
'phone_number': user.phone_number,
|
||||||
'pincode': user.pincode,
|
'pincode': user.pincode,
|
||||||
'district': user.district,
|
'district': user.district,
|
||||||
|
'district_changed_at': user.district_changed_at.isoformat() if user.district_changed_at else None,
|
||||||
'state': user.state,
|
'state': user.state,
|
||||||
'country': user.country,
|
'country': user.country,
|
||||||
'place': user.place,
|
'place': user.place,
|
||||||
|
|||||||
Reference in New Issue
Block a user