Add EventSeries model to hold recurrence data, implement screens to view, edit and delete Series. Show recurrence rules for events that are part of a Series.

This commit is contained in:
Michael Hall 2018-04-14 11:14:40 -04:00
parent 74ee8e2d31
commit c63eaa6436
15 changed files with 420 additions and 46 deletions

View file

@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe
from .models.locale import Language, Continent, Country, SPR, City
from .models.profiles import UserProfile, Organization, Team, Member, Category, Topic
from .models.search import Searchable
from .models.events import Place, Event, EventComment, EventPhoto, CommonEvent, Attendee
from .models.events import Place, Event, EventComment, EventSeries, EventPhoto, CommonEvent, Attendee
admin.site.register(Language)
admin.site.register(Continent)
@ -81,6 +81,15 @@ class CommonEventAdmin(admin.ModelAdmin):
participant_count.short_description = 'Participants'
admin.site.register(CommonEvent, CommonEventAdmin)
class EventSeriesAdmin(admin.ModelAdmin):
raw_id_fields = ('place', 'team')
list_display = ('__str__', 'instance_count', 'team', 'start_time', 'last_time')
ordering = ('-last_time',)
def instance_count(self, series):
return series.instances.all().count()
instance_count.short_description = 'Instances'
admin.site.register(EventSeries, EventSeriesAdmin)
class MemberAdmin(admin.ModelAdmin):
list_display = ('__str__', 'role')
list_filter = ('role', 'team')

View file

@ -7,7 +7,7 @@ from django.utils import timezone
from django.contrib.auth.models import User
from .models.locale import Country, SPR, City
from .models.profiles import Team, UserProfile
from .models.events import Event, EventComment ,CommonEvent, Place, EventPhoto
from .models.events import Event, EventComment ,CommonEvent, EventSeries, Place, EventPhoto
import pytz
from datetime import time
@ -167,7 +167,7 @@ class DeleteTeamForm(forms.Form):
class TeamEventForm(forms.ModelForm):
class Meta:
model = Event
fields = ['name', 'start_time', 'end_time', 'summary', 'place', 'web_url', 'announce_url', 'tags']
fields = ['name', 'start_time', 'end_time', 'summary', 'web_url', 'announce_url', 'tags']
widgets = {
'place': Lookup(source=Place),
'start_time': DateTimeWidget,
@ -191,7 +191,7 @@ class TeamEventForm(forms.ModelForm):
class NewTeamEventForm(forms.ModelForm):
class Meta:
model = Event
fields = ['name', 'start_time', 'end_time', 'recurrences', 'summary']
fields = ['name', 'start_time', 'end_time', 'summary']
widgets = {
'start_time': DateTimeWidget,
'end_time': DateTimeWidget
@ -214,6 +214,18 @@ class NewTeamEventForm(forms.ModelForm):
class DeleteEventForm(forms.Form):
confirm = forms.BooleanField(label="Yes, delete event", required=True)
class EventSeriesForm(forms.ModelForm):
class Meta:
model = EventSeries
fields = ['name', 'start_time', 'end_time', 'recurrences', 'summary']
widgets = {
'start_time': TimeWidget,
'end_time': TimeWidget
}
class DeleteEventSeriesForm(forms.Form):
confirm = forms.BooleanField(label="Yes, delete series", required=True)
class UploadEventPhotoForm(forms.ModelForm):
class Meta:
model = EventPhoto

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,39 @@
# Generated by Django 2.0 on 2018-04-14 13:30
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import recurrence.fields
class Migration(migrations.Migration):
dependencies = [
('events', '0024_auto_20180404_0504'),
]
operations = [
migrations.CreateModel(
name='EventSeries',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=150, verbose_name='Event Name')),
('recurrences', recurrence.fields.RecurrenceField(null=True)),
('last_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, help_text='Date and time of the last created instance in this series')),
('start_time', models.TimeField(db_index=True, help_text='Date and time that the event starts', verbose_name='Start Time')),
('end_time', models.TimeField(db_index=True, help_text='Date and time that the event ends', verbose_name='End Time')),
('summary', models.TextField(blank=True, help_text='Summary of the Event', null=True)),
('created_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, help_text='the date and time when the event was created')),
('tags', models.CharField(blank=True, max_length=128, null=True, verbose_name='Keyword Tags')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.UserProfile')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='planned_events', to='events.CommonEvent')),
('place', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='events.Place')),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.Team')),
],
),
migrations.AddField(
model_name='event',
name='series',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='instances', to='events.EventSeries'),
),
]

View file

@ -58,10 +58,10 @@ class Event(models.Model):
name = models.CharField(max_length=150, verbose_name=_('Event Name'))
team = models.ForeignKey(Team, on_delete=models.CASCADE)
parent = models.ForeignKey('CommonEvent', related_name='participating_events', null=True, blank=True, on_delete=models.SET_NULL)
series = models.ForeignKey('EventSeries',related_name='instances', null=True, blank=True, on_delete=models.SET_NULL)
start_time = models.DateTimeField(help_text=_('Date and time that the event starts'), verbose_name=_('Start Time'), db_index=True)
end_time = models.DateTimeField(help_text=_('Date and time that the event ends'), verbose_name=_('End Time'), db_index=True)
recurrences = RecurrenceField(null=True)
summary = models.TextField(help_text=_('Summary of the Event'), blank=True, null=True)
@ -291,3 +291,67 @@ class CommonEvent(models.Model):
def __str__(self):
return self.name
class EventSeries(models.Model):
name = models.CharField(max_length=150, verbose_name=_('Event Name'))
team = models.ForeignKey(Team, on_delete=models.CASCADE)
parent = models.ForeignKey('CommonEvent', related_name='planned_events', null=True, blank=True, on_delete=models.SET_NULL)
recurrences = RecurrenceField(null=True)
last_time = models.DateTimeField(help_text=_('Date and time of the last created instance in this series'), default=timezone.now, db_index=True)
start_time = models.TimeField(help_text=_('Local time that the event starts'), verbose_name=_('Start Time'), db_index=True)
end_time = models.TimeField(help_text=_('Local time that the event ends'), verbose_name=_('End Time'), db_index=True)
summary = models.TextField(help_text=_('Summary of the Event'), blank=True, null=True)
place = models.ForeignKey(Place, blank=True, null=True, on_delete=models.CASCADE)
created_by = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
created_time = models.DateTimeField(help_text=_('the date and time when the event was created'), default=timezone.now, db_index=True)
tags = models.CharField(verbose_name=_("Keyword Tags"), blank=True, null=True, max_length=128)
def create_next_in_series(self):
next_date = self.recurrences.after(self.last_time, dtstart=self.last_time)
event_tz = pytz.timezone(self.tz)
next_start = pytz.utc.localize(timezone.make_naive(event_tz.localize(datetime.datetime.combine(next_date.date(), self.start_time))))
next_end = pytz.utc.localize(timezone.make_naive(event_tz.localize(datetime.datetime.combine(next_date.date(), self.end_time))))
next_event = Event(
series=self,
team=self.team,
name=self.name,
start_time=next_start,
end_time=next_end,
summary=self.summary,
place=self.place,
created_by=self.created_by,
)
next_event.save()
self.last_time = next_event.start_time
self.save()
return next_event
def get_absolute_url(self):
return reverse('show-series', kwargs={'series_id': self.id, 'series_slug': self.slug})
def get_full_url(self):
site = Site.objects.get(id=1)
return "https://%s%s" % (site.domain, self.get_absolute_url())
@property
def slug(self):
return slugify(self.name)
@property
def tz(self):
if self.place is not None:
return self.place.tz
elif self.team is not None:
return self.team.tz
else:
return settings.TIME_ZONE
def __str__(self):
return u'%s by %s at %s' % (self.name, self.team.name, self.start_time)

View file

@ -98,6 +98,20 @@ class UserProfile(models.Model):
return True
return False
def can_edit_series(self, series):
try:
if self.user.is_superuser:
return True
except:
return False
if series.created_by == self:
return True
if series.team.owner_profile == self:
return True
if self in series.team.moderators:
return True
return False
def can_edit_event(self, event):
try:
if self.user.is_superuser:

View file

@ -0,0 +1,3 @@
<table>
{{ series_form }}
</table>

View file

@ -5,11 +5,6 @@
gtag('config', '{{settings.GOOGLE_ANALYTICS_ID}}', {'page_path': '/team/+create-event/'});
{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{% static 'recurrence/css/recurrence.css' %}">
<script src="{% static 'recurrence/js/recurrence.js' %}"></script>
{% endblock %}
{% block content %}
<h2>Plan a <strong>{{team.name}}</strong> get together</h2>
{% if event.parent %}<p class="text-muted">As part of <a href="{% url "show-common-event" event.parent.id event.parent.slug %}">{{ event.parent.name }}</a></p>{% endif %}

View file

@ -0,0 +1,15 @@
{% extends "get_together/base.html" %}
{% block content %}
<h2>Confirm deletion</h2>
Are you sure you want to delete <strong>{{series.name}}</strong>?
<form action="{% url "delete-series" series.id %}" method="post">
{% csrf_token %}
<div class="form-group">
{{ delete_form }}
<br />
<button type="submit" class="btn btn-danger">Delete Series</button>
</div>
</form>
{% endblock %}

View file

@ -1,4 +1,5 @@
{% extends "get_together/base.html" %}
{% load static %}
{% block content %}
<h2>Updating {{event.name}}</h2>

View file

@ -0,0 +1,52 @@
{% extends "get_together/base.html" %}
{% load static %}
{% block meta %}
<script type="text/javascript" src="{% static 'recurrence/js/recurrence.js' %}"></script>
<script type="text/javascript" src="{% static 'recurrence/js/recurrence-widget.js' %}"></script>
<link href="{% static 'recurrence/css/recurrence.css' %}" rel="stylesheet">
{% endblock %}
{% block content %}
<h2>Updating {{series.name}}</h2>
<form action="{% url "edit-series" series.id%}" method="post">
{% csrf_token %}
{% include "events/series_form.html" %}
<br />
<button type="submit" class="btn btn-primary">Save</button>
</form>
<a href="{% url 'delete-series' series.id %}" class="btn btn-danger">Delete</a>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(document).ready(function(){
$("#place_search").keyup(function() {
var searchText = this.value;
$.getJSON("/api/places/?q="+searchText, function(data, status) {
var searchField = $("#place_search")[0];
var q = this.url.match(/q=([^&]*)/)[1]
var c = searchField.value
if (c != q) return;
var selectField = $("#place_select");
selectField.empty();
$.each(data, function(){
selectField.append('<option value="'+ this.id +'">'+ this.name+' '+this.city + '</option>')
});
});
});
$.datepicker.setDefaults({
showOn: 'focus',
dateFormat: 'yy-mm-dd',
});
$("#id_start_time_0").datepicker({altField: "#id_end_time_0", altFormat: "yy-mm-dd"});
$("#id_end_time_0").datepicker();
});
</script>
{% endblock %}

View file

@ -54,9 +54,19 @@
<td width="120px"><b>Part of:</b></td><td><a href="{{ event.parent.get_absolute_url }}" target="_blank">{{ event.parent.name }}</a></td>
</tr>
{% endif %}
{% if event.series %}
<tr>
<td width="120px"><b>Repeats:</b></td><td><a href="{{ event.series.get_absolute_url }}">
{% for rule in event.series.recurrences.rrules %}
{{rule.to_text|capfirst}}
{% endfor %}</a>
</td>
</tr>
{% endif %}
<tr>
<td width="120px"><b>Time:</b></td><td>{{ event.local_start_time }} - {{ event.local_end_time }}</td>
</tr><tr>
</tr>
<tr>
<td width="120px"><b>Place:</b></td><td>
{% if event.place %}
<a class="" href="{% url 'show-place' event.place.id %}">{{ event.place.name }}</a>
@ -75,9 +85,6 @@
{% endif %}
</table>
<div class="container mt-3">
</div>
<div class="container mt-3">
<div class="row">
<div class="col"><hr/><h4>Comments</h4></div>

View file

@ -0,0 +1,108 @@
{% extends "get_together/base.html" %}
{% load markup static tz %}
{% block add_to_title %} | {{series.name}}{% endblock %}
{% block meta %}
<meta property="og:url" content="{{series.get_full_url}}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{{series.name}}" />
<meta property="og:description" content="{{series.summary}}" />
{% if series.team.category %}
<meta property="og:image" content="{{series.team.category.img_url}}" />
{% else %}
<meta property="og:image" content="https://gettogether.community{% static 'img/team_placeholder.png' %}" />
{% endif %}
{% endblock %}
{% block styles %}
<link href="{% static 'css/bootstrap-album.css' %}" rel="stylesheet"/>
<style>
.gt-profile {
position: relative;
}
.gt-profile .gt-profile-badges {
position: relative;
top: 16px;
left: -42px;
}
</style>
{% endblock %}
{% block content %}
<div class="fluid-container">
<div class="row">
<div class="col-md-9">
<h2>{{ series.name }}
{% if can_edit_event %}
<a href="{% url 'edit-series' series.id %}" class="btn btn-secondary btn-sm">Edit Series</a>
{% endif %}
</h2>
<p class="text-muted">Hosted by <a href="{% url "show-team" team.id %}">{{ team.name }}</a></p>
<hr/>
<p>{{ series.summary|markdown }}</p>
<table class="table">
{% if series.parent %}
<tr>
<td width="120px"><b>Part of:</b></td><td><a href="{{ series.parent.get_absolute_url }}" target="_blank">{{ series.parent.name }}</a></td>
</tr>
{% endif %}
<tr>
<td width="120px"><b>Repeats:</b></td><td>
{% for rule in series.recurrences.rrules %}
{{rule.to_text|capfirst}}
{% endfor %}
</td>
</tr>
<tr>
<td width="120px"><b>Time:</b></td><td>{{ series.start_time }} - {{ series.end_time }}</td>
</tr>
<tr>
<td width="120px"><b>Place:</b></td><td>
{% if series.place %}
<a class="" href="{% url 'show-place' series.place.id %}">{{ series.place.name }}</a>
{% if can_edit_event %}<a href="{% url 'add-place' series.id %}" class="btn btn-secondary btn-sm">Change</a>{% endif %}
{% elif can_edit_event %}
<a class="" href="{% url 'add-place' series.id %}">No place selected yet.</a>
{% else %}
No place selected yet.
{% endif %}
</td>
</tr>
{% if series.web_url %}
<tr>
<td width="120px"><b>Website:</b></td><td><a href="{{ series.web_url }}" target="_blank">{{ series.web_url }}</a></td>
</tr>
{% endif %}
</table>
<div class="container mt-3">
</div>
</div>
<div class="col-md-3">
<div class="container">
<div class="row">
<div class="col"><h4>Instances</h4><hr/></div>
</div>
{% for instance in instances %}
<div class="row mb-3">
<div class="col media gt-profile">
<div class="media-body">
<h6 class="mt-2 mb-0">
<a href="{{ instance.get_absolute_url }}">{{instance.local_start_time.date}}</a>
<br/><small class="text-muted">{{ instance.name }}</small>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -71,6 +71,9 @@ urlpatterns = [
path('events/<int:event_id>/+comment/', event_views.comment_event, name='comment-event'),
path('events/<int:event_id>/+photo/', views.add_event_photo, name='add-event-photo'),
path('events/<int:event_id>/<str:event_slug>/', views.show_event, name='show-event'),
path('series/<int:series_id>/+edit/', views.edit_series, name='edit-series'),
path('series/<int:series_id>/+delete/', views.delete_series, name='delete-series'),
path('series/<int:series_id>/<str:series_slug>/', views.show_series, name='show-series'),
path('org/<str:org_slug>/', views.show_org, name='show-org'),
path('org/<str:org_slug>/+create-event/', views.create_common_event, name='create-common-event'),

View file

@ -7,9 +7,19 @@ from django.shortcuts import render, redirect, reverse
from django.http import HttpResponse, JsonResponse
from django.utils import timezone
from events.models.events import Event, CommonEvent, EventPhoto, Place, Attendee
from events.models.events import Event, CommonEvent, EventSeries, EventPhoto, Place, Attendee
from events.models.profiles import Team, Organization, UserProfile, Member
from events.forms import TeamEventForm, NewTeamEventForm, DeleteEventForm, EventCommentForm, NewPlaceForm, UploadEventPhotoForm, NewCommonEventForm
from events.forms import (
TeamEventForm,
NewTeamEventForm,
DeleteEventForm,
EventSeriesForm,
DeleteEventSeriesForm,
EventCommentForm,
NewPlaceForm,
UploadEventPhotoForm,
NewCommonEventForm
)
from events import location
import datetime
@ -49,6 +59,16 @@ def show_event(request, event_id, event_slug):
}
return render(request, 'get_together/events/show_event.html', context)
def show_series(request, series_id, series_slug):
series = EventSeries.objects.get(id=series_id)
context = {
'team': series.team,
'series': series,
'instances': series.instances.all().order_by('-start_time'),
'can_edit_event': request.user.profile.can_create_event(series.team),
}
return render(request, 'get_together/events/show_series.html', context)
@login_required
def create_event_team_select(request):
teams = request.user.profile.moderating
@ -226,6 +246,68 @@ def delete_event(request, event_id):
else:
return redirect('home')
def edit_series(request, series_id):
series = EventSeries.objects.get(id=series_id)
if not request.user.profile.can_edit_series(series):
messages.add_message(request, messages.WARNING, message=_('You can not make changes to this event.'))
return redirect(series.get_absolute_url())
if request.method == 'GET':
form = EventSeriesForm(instance=series)
context = {
'team': series.team,
'series': series,
'series_form': form,
}
return render(request, 'get_together/events/edit_series.html', context)
elif request.method == 'POST':
form = EventSeriesForm(request.POST,instance=series)
if form.is_valid:
new_series = form.save()
return redirect(new_series.get_absolute_url())
else:
context = {
'team': event.team,
'series': series,
'series_form': form,
}
return render(request, 'get_together/events/edit_series.html', context)
else:
return redirect('home')
def delete_series(request, series_id):
series = EventSeries.objects.get(id=series_id)
if not request.user.profile.can_edit_series(series):
messages.add_message(request, messages.WARNING, message=_('You can not make changes to this event.'))
return redirect(series.get_absolute_url())
if request.method == 'GET':
form = DeleteEventSeriesForm()
context = {
'team': series.team,
'series': series,
'delete_form': form,
}
return render(request, 'get_together/events/delete_series.html', context)
elif request.method == 'POST':
form = DeleteEventSeriesForm(request.POST)
if form.is_valid() and form.cleaned_data['confirm']:
team_id = series.team_id
series.delete()
return redirect('show-team', team_id)
else:
context = {
'team': series.team,
'series': series,
'delete_form': form,
}
return render(request, 'get_together/events/delete_series.html', context)
else:
return redirect('home')
def show_common_event(request, event_id, event_slug):
event = CommonEvent.objects.get(id=event_id)
context = {