Convert Event start and end times to UTC, display them in local time but store them in UTC. Show ical feeds for the user and teams. Fixes #28, Fixes #59

This commit is contained in:
Michael Hall 2018-04-01 22:22:30 -04:00
parent 2e21c5789e
commit d440e5b173
29 changed files with 493 additions and 47 deletions

66
events/feeds.py Normal file
View file

@ -0,0 +1,66 @@
from django.contrib.sites.models import Site
from django_ical.views import ICalFeed
from django.utils import timezone
import datetime
from .models.events import Event, CommonEvent
from .models.profiles import UserProfile, Team, Organization
class AbstractEventCalendarFeed(ICalFeed):
def item_guid(self, event):
site = Site.objects.get(id=1)
return '%s@%s' % (event.id, site.domain)
def item_link(self, event):
return event.get_full_url()
def item_title(self, event):
return event.name
def item_description(self, event):
return event.summary
def item_start_datetime(self, event):
return event.start_time
def item_end_datetime(self, event):
return event.end_time
def item_created(self, event):
return event.created_time
def item_location(self, event):
if event.place is not None:
return str(event.place)
return None
def item_geo(self, event):
if event.place is not None:
latitude = event.place.latitude or None
longitude = event.place.longitude or None
return (latitude, longitude)
elif event.team.city is not None:
latitude = event.team.city.latitude
longitude = event.team.city.longitude
return (latitude, longitude)
return None
class UserEventsCalendar(AbstractEventCalendarFeed):
timezone = 'UTC'
def get_object(self, request, account_secret):
return UserProfile.objects.get(secret_key=account_secret)
def items(self, user):
return Event.objects.filter(attendees=user, end_time__gt=timezone.now()).order_by('-start_time')
class TeamEventsCalendar(AbstractEventCalendarFeed):
timezone = 'UTC'
def get_object(self, request, team_id):
return Team.objects.get(id=team_id)
def items(self, team):
return Event.objects.filter(team=team, end_time__gt=timezone.now()).order_by('-start_time')

View file

@ -2,12 +2,14 @@ from django.utils.safestring import mark_safe
from django import forms from django import forms
from django.forms.widgets import TextInput, Media from django.forms.widgets import TextInput, Media
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from django.contrib.auth.models import User from django.contrib.auth.models import User
from .models.locale import Country, SPR, City from .models.locale import Country, SPR, City
from .models.profiles import Team, UserProfile from .models.profiles import Team, UserProfile
from .models.events import Event, EventComment ,CommonEvent, Place, EventPhoto from .models.events import Event, EventComment ,CommonEvent, Place, EventPhoto
import pytz
from datetime import time from datetime import time
from time import strptime, strftime from time import strptime, strftime
@ -171,6 +173,20 @@ class TeamEventForm(forms.ModelForm):
'start_time': DateTimeWidget, 'start_time': DateTimeWidget,
'end_time': DateTimeWidget 'end_time': DateTimeWidget
} }
def __init__(self, *args, **kargs):
super().__init__(*args, **kargs)
event_tz = pytz.timezone(self.instance.tz)
if self.instance.local_start_time: self.initial['start_time'] = self.instance.local_start_time
if self.instance.local_end_time: self.initial['end_time'] = self.instance.local_end_time
print("Initial: %s" % self.initial)
def clean(self):
cleaned_data = super().clean()
event_tz = pytz.timezone(self.instance.tz)
print("Clean: %s" % cleaned_data)
cleaned_data['start_time'] = pytz.utc.localize(timezone.make_naive(event_tz.localize(timezone.make_naive(cleaned_data['start_time']))))
cleaned_data['end_time'] = pytz.utc.localize(timezone.make_naive(event_tz.localize(timezone.make_naive(cleaned_data['end_time']))))
return cleaned_data
class NewTeamEventForm(forms.ModelForm): class NewTeamEventForm(forms.ModelForm):
class Meta: class Meta:
@ -180,6 +196,20 @@ class NewTeamEventForm(forms.ModelForm):
'start_time': DateTimeWidget, 'start_time': DateTimeWidget,
'end_time': DateTimeWidget 'end_time': DateTimeWidget
} }
def __init__(self, *args, **kargs):
super().__init__(*args, **kargs)
event_tz = pytz.timezone(self.instance.tz)
if self.instance.local_start_time: self.initial['start_time'] = self.instance.local_start_time
if self.instance.local_end_time: self.initial['end_time'] = self.instance.local_end_time
print("Initial: %s" % self.initial)
def clean(self):
cleaned_data = super().clean()
event_tz = pytz.timezone(self.instance.tz)
print("Clean: %s" % cleaned_data)
cleaned_data['start_time'] = pytz.utc.localize(timezone.make_naive(event_tz.localize(timezone.make_naive(cleaned_data['start_time']))))
cleaned_data['end_time'] = pytz.utc.localize(timezone.make_naive(event_tz.localize(timezone.make_naive(cleaned_data['end_time']))))
return cleaned_data
class DeleteEventForm(forms.Form): class DeleteEventForm(forms.Form):
confirm = forms.BooleanField(label="Yes, delete event", required=True) confirm = forms.BooleanField(label="Yes, delete event", required=True)

79
events/location.py Normal file
View file

@ -0,0 +1,79 @@
from django.utils import timezone
from django.conf import settings
import math
import pytz
import datetime
import geocoder
KM_PER_DEGREE_LAT = 110.574
KM_PER_DEGREE_LNG = 111.320 # At the equator
class TimezoneChoices():
def __iter__(self):
for tz in pytz.all_timezones:
yield (tz, tz)
def get_client_ip(request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def get_geoip(request):
client_ip = get_client_ip(request)
if client_ip == '127.0.0.1' or client_ip == 'localhost':
if settings.DEBUG:
client_ip = '8.8.8.8' # Try Google's server
print("Client is localhost, using 8.8.8.8 for geoip instead")
else:
raise Exception("Client is localhost")
g = geocoder.ip(client_ip)
return g
def get_bounding_box(center, radius):
minlat = center[0]-(radius/KM_PER_DEGREE_LAT)
maxlat = center[0]+(radius/KM_PER_DEGREE_LAT)
minlng = center[1]-(radius/(KM_PER_DEGREE_LNG*math.cos(math.radians(center[0]))))
maxlng = center[1]+(radius/(KM_PER_DEGREE_LNG*math.cos(math.radians(center[0]))))
return (minlat, maxlat, minlng, maxlng)
def distance(center1, center2):
avglat = (center2[0] + center1[0])/2
dlat = (center2[0] - center1[0]) * KM_PER_DEGREE_LAT
dlng = (center2[1] - center1[1]) * (KM_PER_DEGREE_LNG*math.cos(math.radians(avglat)))
dkm = math.sqrt((dlat*dlat) + (dlng*dlng))
print("Distance between %s and %s is %s" % (center1, center2, dkm))
return dkm
def city_distance_from(ll, city):
if city is not None and city.latitude is not None and city.longitude is not None:
return distance((ll), (city.latitude, city.longitude))
else:
return 99999
def team_distance_from(ll, team):
if team.city is not None:
return city_distance_from(ll, team.city)
else:
return 99999
def event_distance_from(ll, event):
if event.place is not None and event.place.latitude is not None and event.place.longitude is not None:
return distance((ll), (event.place.latitude, event.place.longitude))
if event.team is not None:
return team_distance_from(ll, event.team)
else:
return 99999
def searchable_distance_from(ll, searchable):
if searchable.latitude is not None and searchable.longitude is not None:
return distance((ll), (float(searchable.latitude), float(searchable.longitude)))
else:
return 99999

View file

@ -0,0 +1,12 @@
from django.core.management.base import BaseCommand, CommandError
from events.models.events import Event, update_event_searchable
import urllib
import datetime
class Command(BaseCommand):
help = 'Regenerated Searchable records from this node'
def handle(self, *args, **options):
for event in Event.objects.all():
update_event_searchable(event)

View file

@ -0,0 +1,31 @@
# Generated by Django 2.0 on 2018-03-27 15:54
from django.db import migrations, models
import django.db.models.deletion
import uuid
def gen_unique_keys(apps, schema_editor):
UserProfile = apps.get_model('events', 'UserProfile')
for profile in UserProfile.objects.all():
profile.secret_key = uuid.uuid4()
profile.save()
class Migration(migrations.Migration):
dependencies = [
('events', '0020_add_event_comments'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='secret_key',
field=models.UUIDField(default=uuid.uuid4),
),
migrations.RunPython(gen_unique_keys, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='eventcomment',
name='event',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='events.Event'),
),
]

View file

@ -0,0 +1,47 @@
# Generated by Django 2.0 on 2018-04-01 15:11
from django.db import migrations
from django.utils import timezone
import pytz
def localize_event_datetimes(apps, schema_editor):
Event = MyModel = apps.get_model('events', 'Event')
for event in Event.objects.all():
utc_tz = pytz.timezone('UTC')
event_tz = get_event_timezone(event)
print("Converting event %s to %s" % (event.id, event_tz))
event.start_time = utc_tz.localize(timezone.make_naive(event_tz.localize(timezone.make_naive(event.start_time, pytz.utc))))
event.end_time = utc_tz.localize(timezone.make_naive(event_tz.localize(timezone.make_naive(event.end_time, pytz.utc))))
event.save()
def localize_commonevent_datetimes(apps, schema_editor):
CommonEvent = MyModel = apps.get_model('events', 'CommonEvent')
for event in CommonEvent.objects.all():
utc_tz = pytz.timezone('UTC')
event_tz = get_event_timezone(event)
print("Converting common event %s to %s" % (event.id, event_tz))
event.start_time = utc_tz.localize(timezone.make_naive(event_tz.localize(timezone.make_naive(event.start_time, pytz.utc))))
event.end_time = utc_tz.localize(timezone.make_naive(event_tz.localize(timezone.make_naive(event.end_time, pytz.utc))))
event.save()
def get_event_timezone(event):
if event.place is not None:
return pytz.timezone(event.place.tz)
elif hasattr(event, 'team') and event.team is not None:
return pytz.timezone(event.team.tz)
elif hasattr(event, 'city') and event.city is not None:
return pytz.timezone(event.city.tz)
else:
return pytz.timezone("UTC")
class Migration(migrations.Migration):
dependencies = [
('events', '0021_add_account_secret'),
]
operations = [
migrations.RunPython(localize_event_datetimes, reverse_code=migrations.RunPython.noop),
migrations.RunPython(localize_commonevent_datetimes, reverse_code=migrations.RunPython.noop),
]

File diff suppressed because one or more lines are too long

View file

@ -3,6 +3,7 @@ from django.contrib.sites.models import Site
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.shortcuts import reverse from django.shortcuts import reverse
from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
@ -13,6 +14,7 @@ from imagekit.processors import ResizeToFill
from .locale import * from .locale import *
from .profiles import * from .profiles import *
from .search import * from .search import *
from .. import location
import re import re
import pytz import pytz
@ -22,14 +24,13 @@ import hashlib
SLUG_OK = '-_~' SLUG_OK = '-_~'
class Place(models.Model): class Place(models.Model):
name = models.CharField(help_text=_('Name of the Place'), max_length=150) name = models.CharField(help_text=_('Name of the Place'), max_length=150)
city = models.ForeignKey(City, verbose_name=_('City'), on_delete=models.CASCADE) city = models.ForeignKey(City, verbose_name=_('City'), on_delete=models.CASCADE)
address = models.CharField(help_text=_('Address with Street and Number'), max_length=150, null=True, blank=True) address = models.CharField(help_text=_('Address with Street and Number'), max_length=150, null=True, blank=True)
longitude = models.FloatField(help_text=_('Longitude in Degrees East'), null=True, blank=True) longitude = models.FloatField(help_text=_('Longitude in Degrees East'), null=True, blank=True)
latitude = models.FloatField(help_text=_('Latitude in Degrees North'), null=True, blank=True) latitude = models.FloatField(help_text=_('Latitude in Degrees North'), null=True, blank=True)
tz = models.CharField(max_length=32, verbose_name=_('Timezone'), default='UTC', choices=[(tz, tz) for tz in pytz.all_timezones], blank=False, null=False) tz = models.CharField(max_length=32, verbose_name=_('Timezone'), default='UTC', choices=location.TimezoneChoices(), blank=False, null=False)
place_url = models.URLField(help_text=_('URL for the Place Homepage'), verbose_name=_('URL of the Place'), max_length=200, blank=True, null=True) place_url = models.URLField(help_text=_('URL for the Place Homepage'), verbose_name=_('URL of the Place'), max_length=200, blank=True, null=True)
cover_img = models.URLField(_("Place photo"), null=True, blank=True) cover_img = models.URLField(_("Place photo"), null=True, blank=True)
@ -58,8 +59,8 @@ class Event(models.Model):
team = models.ForeignKey(Team, on_delete=models.CASCADE) 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) parent = models.ForeignKey('CommonEvent', related_name='participating_events', null=True, blank=True, on_delete=models.SET_NULL)
start_time = models.DateTimeField(help_text=_('Local date and time that the event starts'), verbose_name=_('Local Start Time'), db_index=True) 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=_('Local date and time that the event ends'), verbose_name=_('Local End Time'), db_index=True) end_time = models.DateTimeField(help_text=_('Date and 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) 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) place = models.ForeignKey(Place, blank=True, null=True, on_delete=models.CASCADE)
@ -68,7 +69,7 @@ class Event(models.Model):
announce_url = models.URLField(verbose_name=_('Announcement'), help_text=_('URL for the announcement'), max_length=200, blank=True, null=True) announce_url = models.URLField(verbose_name=_('Announcement'), help_text=_('URL for the announcement'), max_length=200, blank=True, null=True)
created_by = models.ForeignKey(UserProfile, 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=datetime.datetime.now, db_index=True) 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) tags = models.CharField(verbose_name=_("Keyword Tags"), blank=True, null=True, max_length=128)
#image #image
@ -76,6 +77,35 @@ class Event(models.Model):
attendees = models.ManyToManyField(UserProfile, through='Attendee', related_name="attending", blank=True) attendees = models.ManyToManyField(UserProfile, through='Attendee', related_name="attending", blank=True)
@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
@property
def local_start_time(self, val=None):
if val is not None:
self.start_time = val.astimezone(python.utc)
else:
if self.start_time is None:
return None
event_tz = pytz.timezone(self.tz)
return timezone.make_naive(self.start_time.astimezone(event_tz), event_tz)
@property
def local_end_time(self, val=None):
if val is not None:
self.end_time = val.astimezone(python.utc)
else:
if self.end_time is None:
return None
event_tz = pytz.timezone(self.tz)
return timezone.make_naive(self.end_time.astimezone(event_tz), event_tz)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('show-event', kwargs={'event_id': self.id, 'event_slug': self.slug}) return reverse('show-event', kwargs={'event_id': self.id, 'event_slug': self.slug})
@ -112,7 +142,7 @@ def update_event_searchable(event):
searchable = Searchable(event_uri) searchable = Searchable(event_uri)
searchable.origin_node = origin_url searchable.origin_node = origin_url
searchable.federation_node = origin_url searchable.federation_node = origin_url
searchable.federation_time = datetime.datetime.now() searchable.federation_time = timezone.now()
searchable.event_url = event_url searchable.event_url = event_url
@ -124,6 +154,7 @@ def update_event_searchable(event):
searchable.group_name = event.team.name searchable.group_name = event.team.name
searchable.start_time = event.start_time searchable.start_time = event.start_time
searchable.end_time = event.end_time searchable.end_time = event.end_time
searchable.tz = event.tz
searchable.cost = 0 searchable.cost = 0
searchable.tags = event.tags searchable.tags = event.tags
if (event.place is not None): if (event.place is not None):
@ -176,7 +207,7 @@ class Attendee(models.Model):
user = models.ForeignKey(UserProfile, on_delete=models.CASCADE) user = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
role = models.SmallIntegerField(_("Role"), choices=ROLES, default=NORMAL, db_index=True) role = models.SmallIntegerField(_("Role"), choices=ROLES, default=NORMAL, db_index=True)
status = models.SmallIntegerField(_("Attending?"), choices=STATUSES, default=YES, db_index=True) status = models.SmallIntegerField(_("Attending?"), choices=STATUSES, default=YES, db_index=True)
joined_date = models.DateTimeField(default=datetime.datetime.now) joined_date = models.DateTimeField(default=timezone.now)
@property @property
def role_name(self): def role_name(self):
@ -212,7 +243,7 @@ class EventComment(MPTTModel):
author = models.ForeignKey(UserProfile, on_delete=models.CASCADE) author = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
event = models.ForeignKey(Event, related_name='comments', on_delete=models.CASCADE) event = models.ForeignKey(Event, related_name='comments', on_delete=models.CASCADE)
body = models.TextField() body = models.TextField()
created_time = models.DateTimeField(default=datetime.datetime.now, db_index=True) created_time = models.DateTimeField(default=timezone.now, db_index=True)
status = models.SmallIntegerField(choices=STATUSES, default=APPROVED, db_index=True) status = models.SmallIntegerField(choices=STATUSES, default=APPROVED, db_index=True)
parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.SET_NULL) parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.SET_NULL)
@ -224,8 +255,8 @@ class CommonEvent(models.Model):
organization = models.ForeignKey(Organization, null=True, blank=True, on_delete=models.CASCADE) organization = models.ForeignKey(Organization, null=True, blank=True, on_delete=models.CASCADE)
parent = models.ForeignKey('CommonEvent', related_name='sub_events', null=True, blank=True, on_delete=models.SET_NULL) parent = models.ForeignKey('CommonEvent', related_name='sub_events', null=True, blank=True, on_delete=models.SET_NULL)
start_time = models.DateTimeField(help_text=_('Local date and time that the event starts'), verbose_name=_('Local Start Time'), db_index=True) 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=_('Local date and time that the event ends'), verbose_name=_('Local End Time'), db_index=True) end_time = models.DateTimeField(help_text=_('Date and 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) summary = models.TextField(help_text=_('Summary of the Event'), blank=True, null=True)
country = models.ForeignKey(Country, null=True, blank=True, on_delete=models.SET_NULL) country = models.ForeignKey(Country, null=True, blank=True, on_delete=models.SET_NULL)
@ -237,7 +268,7 @@ class CommonEvent(models.Model):
announce_url = models.URLField(verbose_name=_('Announcement'), help_text=_('URL for the announcement'), max_length=200, blank=True, null=True) announce_url = models.URLField(verbose_name=_('Announcement'), help_text=_('URL for the announcement'), max_length=200, blank=True, null=True)
created_by = models.ForeignKey(UserProfile, 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=datetime.datetime.now, db_index=True) created_time = models.DateTimeField(help_text=_('the date and time when the event was created'), default=timezone.now, db_index=True)
category = models.ForeignKey('Category', on_delete=models.SET_NULL, blank=False, null=True) category = models.ForeignKey('Category', on_delete=models.SET_NULL, blank=False, null=True)
topics = models.ManyToManyField('Topic', blank=True) topics = models.ManyToManyField('Topic', blank=True)

View file

@ -8,7 +8,9 @@ from imagekit.models import ProcessedImageField
from imagekit.processors import ResizeToFill from imagekit.processors import ResizeToFill
from .locale import * from .locale import *
from .. import location
import uuid
import pytz import pytz
import datetime import datetime
import hashlib import hashlib
@ -18,7 +20,7 @@ class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE) user = models.OneToOneField(User, on_delete=models.CASCADE)
realname = models.CharField(verbose_name=_("Real Name"), max_length=150, blank=True) realname = models.CharField(verbose_name=_("Real Name"), max_length=150, blank=True)
tz = models.CharField(max_length=32, verbose_name=_('Timezone'), default='UTC', choices=[(tz, tz) for tz in pytz.all_timezones], blank=False, null=False) tz = models.CharField(max_length=32, verbose_name=_('Timezone'), default='UTC', choices=location.TimezoneChoices(), blank=False, null=False)
avatar = ProcessedImageField(verbose_name=_("Photo Image"), avatar = ProcessedImageField(verbose_name=_("Photo Image"),
upload_to='avatars', upload_to='avatars',
processors=[ResizeToFill(128, 128)], processors=[ResizeToFill(128, 128)],
@ -31,6 +33,8 @@ class UserProfile(models.Model):
send_notifications = models.BooleanField(verbose_name=_('Send notification emails'), default=True) send_notifications = models.BooleanField(verbose_name=_('Send notification emails'), default=True)
secret_key = models.UUIDField(default=uuid.uuid4, editable=True)
categories = models.ManyToManyField('Category', blank=True) categories = models.ManyToManyField('Category', blank=True)
topics = models.ManyToManyField('Topic', blank=True) topics = models.ManyToManyField('Topic', blank=True)
@ -196,7 +200,7 @@ class Team(models.Model):
cover_img = models.URLField(_("Team Photo"), null=True, blank=True) cover_img = models.URLField(_("Team Photo"), null=True, blank=True)
languages = models.ManyToManyField(Language, blank=True) languages = models.ManyToManyField(Language, blank=True)
active = models.BooleanField(_("Active Team"), default=True) active = models.BooleanField(_("Active Team"), default=True)
tz = models.CharField(max_length=32, verbose_name=_('Default Timezone'), default='UTC', choices=[(tz, tz) for tz in pytz.all_timezones], blank=False, null=False, help_text=_('The most commonly used timezone for this Team.')) tz = models.CharField(max_length=32, verbose_name=_('Default Timezone'), default='UTC', choices=location.TimezoneChoices(), blank=False, null=False, help_text=_('The most commonly used timezone for this Team.'))
members = models.ManyToManyField(UserProfile, through='Member', related_name="memberships", blank=True) members = models.ManyToManyField(UserProfile, through='Member', related_name="memberships", blank=True)

View file

@ -1,7 +1,12 @@
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
from .. import location
import pytz
import datetime import datetime
# Provides a searchable index of events that may belong to this site or a federated site # Provides a searchable index of events that may belong to this site or a federated site
@ -17,6 +22,7 @@ class Searchable(models.Model):
latitude = models.DecimalField(max_digits=12, decimal_places=8, null=True, blank=True) latitude = models.DecimalField(max_digits=12, decimal_places=8, null=True, blank=True)
start_time = models.DateTimeField() start_time = models.DateTimeField()
end_time = models.DateTimeField() end_time = models.DateTimeField()
tz = models.CharField(max_length=32, verbose_name=_('Default Timezone'), default='UTC', choices=location.TimezoneChoices(), blank=False, null=False, help_text=_('The most commonly used timezone for this Team.'))
cost = models.PositiveSmallIntegerField(default=0, blank=True) cost = models.PositiveSmallIntegerField(default=0, blank=True)
tags = models.CharField(blank=True, null=True, max_length=128) tags = models.CharField(blank=True, null=True, max_length=128)
@ -27,6 +33,26 @@ class Searchable(models.Model):
def __str__(self): def __str__(self):
return u'%s' % (self.event_url) return u'%s' % (self.event_url)
@property
def local_start_time(self, val=None):
if val is not None:
self.start_time = val.astimezone(python.utc)
else:
if self.start_time is None:
return None
event_tz = pytz.timezone(self.tz)
return timezone.make_naive(self.start_time.astimezone(event_tz), event_tz)
@property
def local_end_time(self, val=None):
if val is not None:
self.end_time = val.astimezone(python.utc)
else:
if self.end_time is None:
return None
event_tz = pytz.timezone(self.tz)
return timezone.make_naive(self.end_time.astimezone(event_tz), event_tz)
class SearchableSerializer(serializers.ModelSerializer): class SearchableSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Searchable model = Searchable

View file

@ -139,6 +139,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
USE_TZ = True
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
USE_I18N = True USE_I18N = True

View file

@ -28,6 +28,7 @@
self.options.search(this.value, function(searchText, results){ self.options.search(this.value, function(searchText, results){
if (searchText != self.searchField[0].value) return ; if (searchText != self.searchField[0].value) return ;
self.current_data = results;
self.element.empty(); self.element.empty();
var selected = " selected" var selected = " selected"
self.element.append('<option value="">--------</option>') self.element.append('<option value="">--------</option>')
@ -43,7 +44,33 @@
open: function(event) { open: function(event) {
this._super() this._super()
this.searchField.focus() this.searchField.focus()
} },
_select: function( item, event ) {
var oldIndex = this.element[ 0 ].selectedIndex;
// Change native select element
this.element[ 0 ].selectedIndex = item.index;
this.buttonItem.replaceWith( this.buttonItem = this._renderButtonItem( item ) );
this._setAria( item );
// Get lookup data for this item
var data = this.current_data[item.index-1]
this._trigger( "select", event, { item: item, data: data } );
if ( item.index !== oldIndex ) {
this._trigger( "change", event, { item: item } );
}
this.close( event );
},
get_data_for: function(index) {
if (this.current_data != null && current_data.length > index) {
return this.current_data[index];
} else {
return null;
}
},
}); });
}(jQuery)); }(jQuery));

View file

@ -45,10 +45,10 @@
<div class="collapse navbar-collapse" id="navbarsExampleDefault"> <div class="collapse navbar-collapse" id="navbarsExampleDefault">
<ul class="navbar-nav mr-auto"> <ul class="navbar-nav mr-auto">
<li class="nav-item{% if request.resolver_match.url_name == "events" %} active{% endif %}"> <li class="nav-item{% if request.resolver_match.url_name == "events" or request.resolver_match.url_name == "all-events" %} active{% endif %}">
<a class="nav-link" href="{% url 'events' %}">Events{% if request.resolver_match.url_name == "events" %} <span class="sr-only">(current)</span>{% endif %}</a> <a class="nav-link" href="{% url 'events' %}">Events{% if request.resolver_match.url_name == "events" %} <span class="sr-only">(current)</span>{% endif %}</a>
</li> </li>
<li class="nav-item{% if request.resolver_match.url_name == "teams" %} active{% endif %}"> <li class="nav-item{% if request.resolver_match.url_name == "teams" or request.resolver_match.url_name == "all-teams" %} active{% endif %}">
<a class="nav-link" href="{% url 'teams' %}">Teams{% if request.resolver_match.url_name == "teams" %} <span class="sr-only">(current)</span>{% endif %}</a> <a class="nav-link" href="{% url 'teams' %}">Teams{% if request.resolver_match.url_name == "teams" %} <span class="sr-only">(current)</span>{% endif %}</a>
</li> </li>
{% comment %} {% comment %}

View file

@ -10,7 +10,7 @@
<form action="{% url "create-event" team.id%}" method="post"> <form action="{% url "create-event" team.id%}" method="post">
{% csrf_token %} {% csrf_token %}
{% if event.parent %}<<input type="hidden" name="common" value="{{event.parent.id}}" />{% endif %} {% if event.parent %}<input type="hidden" name="common" value="{{event.parent.id}}" />{% endif %}
<div class="form-group"> <div class="form-group">
{% include "events/event_form.html" %} {% include "events/event_form.html" %}
<br /> <br />

View file

@ -1,5 +1,5 @@
{% extends "get_together/base.html" %} {% extends "get_together/base.html" %}
{% load static %} {% load static tz %}
{% block styles %} {% block styles %}
<link href="{% static 'css/bootstrap-album.css' %}" rel="stylesheet"/> <link href="{% static 'css/bootstrap-album.css' %}" rel="stylesheet"/>
@ -29,7 +29,7 @@
<div class="card-body"> <div class="card-body">
<p class="card-text"><strong><a class="card-link" href="{{event.get_absolute_url}}">{{event.name}}</a></strong></p> <p class="card-text"><strong><a class="card-link" href="{{event.get_absolute_url}}">{{event.name}}</a></strong></p>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<small class="text-muted">{{ event.start_time }}</small> <small class="text-muted">{{ event.local_start_time }}</small>
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-primary" href="{{ event.get_absolute_url }}">View</a></span> <a class="btn btn-primary" href="{{ event.get_absolute_url }}">View</a></span>
</div> </div>

View file

@ -1,5 +1,5 @@
{% extends "get_together/base.html" %} {% extends "get_together/base.html" %}
{% load markup static %} {% load markup static tz %}
{% block add_to_totle %} | {{event.name}}{% endblock %} {% block add_to_totle %} | {{event.name}}{% endblock %}
@ -51,7 +51,7 @@
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<td><b>Time:</b></td><td>{{ event.start_time }} - {{ event.end_time }}</td> <td><b>Time:</b></td><td>{{ event.local_start_time }} - {{ event.local_end_time }}</td>
</tr><tr> </tr><tr>
<td><b>Place:</b></td><td> <td><b>Place:</b></td><td>
{% if event.place %} {% if event.place %}

View file

@ -20,7 +20,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
{% if near_events.count > 0 %} {% if near_events %}
{% for event in near_events %} {% for event in near_events %}
<div class="col-md-4"> <div class="col-md-4">
<div class="card mb-4 box-shadow"> <div class="card mb-4 box-shadow">
@ -37,7 +37,7 @@
<div class="card-body"> <div class="card-body">
<p class="card-text"><strong><a class="card-link" href="{{event.event_url}}">{{event.event_title}}</a></strong></p> <p class="card-text"><strong><a class="card-link" href="{{event.event_url}}">{{event.event_title}}</a></strong></p>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<small class="text-muted">{{ event.start_time }}<br/>{{event.location_name}}</small> <small class="text-muted">{{ event.local_start_time }}<br/>{{event.location_name}}</small>
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-primary" href="{{event.event_url}}">View</a></span> <a class="btn btn-primary" href="{{event.event_url}}">View</a></span>
</div> </div>
@ -61,7 +61,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
{% if near_teams.count > 0 %} {% if near_teams %}
{% for team in near_teams %} {% for team in near_teams %}
<div class="col-md-4"> <div class="col-md-4">
<div class="card mb-4 box-shadow"> <div class="card mb-4 box-shadow">

View file

@ -86,7 +86,7 @@
<div class="card-body"> <div class="card-body">
<p class="card-text"><strong><a class="card-link" href="{{event.get_absolute_url}}">{{event.name}}</a></strong></p> <p class="card-text"><strong><a class="card-link" href="{{event.get_absolute_url}}">{{event.name}}</a></strong></p>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<small class="text-muted">{{ event.start_time }}</small> <small class="text-muted">{{ event.local_start_time }}</small>
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-primary" href="{{ event.get_absolute_url }}">View</a></span> <a class="btn btn-primary" href="{{ event.get_absolute_url }}">View</a></span>
</div> </div>

View file

@ -83,7 +83,8 @@
selectField.append('<option value="'+ json.id +'" selected>'+ json.display + '</option>'); selectField.append('<option value="'+ json.id +'" selected>'+ json.display + '</option>');
selectField.lookup("refresh"); selectField.lookup("refresh");
$("#id_tz").val(json.tz) $("#id_tz").val(json.tz);
$("#id_tz").selectmenu("refresh");
} }
} }
); );
@ -216,6 +217,10 @@ $(document).ready(function(){
return callback(q, data); return callback(q, data);
}); });
},
select: function( event, ui ) {
$("#id_tz").val(ui.data.tz);
$("#id_tz").selectmenu("refresh");
} }
}) })
$("#id_tz").selectmenu(); $("#id_tz").selectmenu();

View file

@ -56,7 +56,7 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
<h6 class="mt-2 mb-0"><a href="{{event.get_absolute_url}}">{{event.name}}</a></h6> <h6 class="mt-2 mb-0"><a href="{{event.get_absolute_url}}">{{event.name}}</a></h6>
<small class="text-muted">{{ event.start_time }}</small> <small class="text-muted">{{ event.local_start_time }}</small>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View file

@ -28,6 +28,10 @@ $(document).ready(function(){
return callback(q, data); return callback(q, data);
}); });
},
select: function( event, ui ) {
$("#id_tz").val(ui.data.tz);
$("#id_tz").selectmenu("refresh");
} }
}) })
$("#id_category").selectmenu(); $("#id_category").selectmenu();

View file

@ -17,7 +17,7 @@
$(document).ready(function(){ $(document).ready(function(){
$("#city_select").lookup({ $("#city_select").lookup({
search: function(searchText, callback) { search: function(searchText, callback) {
if (searchText.length < 3) return callback(searchText, []); if (searchText.length < 3) return callback(searchText, []);
$.getJSON("/api/cities/?q="+searchText, function(data) { $.getJSON("/api/cities/?q="+searchText, function(data) {
var m = this.url.match(/q=([^&]+)/); var m = this.url.match(/q=([^&]+)/);
var q = ""; var q = "";
@ -27,8 +27,12 @@ $(document).ready(function(){
return callback(q, data); return callback(q, data);
}); });
},
select: function( event, ui ) {
$("#id_tz").val(ui.data.tz);
$("#id_tz").selectmenu("refresh");
} }
}) });
$("#id_category").selectmenu(); $("#id_category").selectmenu();
$("#id_tz").selectmenu(); $("#id_tz").selectmenu();
}); });

View file

@ -1,5 +1,5 @@
{% extends "get_together/base.html" %} {% extends "get_together/base.html" %}
{% load markup %} {% load markup tz %}
{% block styles %} {% block styles %}
<style> <style>
@ -28,6 +28,7 @@
{% else %} {% else %}
<a href="{% url 'join-team' team.id %}" class="btn btn-success btn-sm">Join Team</a> <a href="{% url 'join-team' team.id %}" class="btn btn-success btn-sm">Join Team</a>
{% endif %} {% endif %}
<a href="{% url 'team-event-ical' team.id %}" class="btn btn-success btn-sm">iCal</a>
</h2><hr/> </h2><hr/>
{% if team.description %}<p>{{ team.description|markdown }}</p><hr/>{% endif %} {% if team.description %}<p>{{ team.description|markdown }}</p><hr/>{% endif %}
@ -38,7 +39,7 @@
<div class="row"> <div class="row">
<div class="col"><a href="{{ event.get_absolute_url }}">{{event.name}}</a></div> <div class="col"><a href="{{ event.get_absolute_url }}">{{event.name}}</a></div>
<div class="col">{{ event.place }}</div> <div class="col">{{ event.place }}</div>
<div class="col">{{ event.start_time }}</div> <div class="col">{{ event.local_start_time }}</div>
</div> </div>
{% endfor %} {% endfor %}
{% if can_create_event %} {% if can_create_event %}
@ -58,7 +59,7 @@
<div class="row"> <div class="row">
<div class="col"><a href="{{ event.get_absolute_url }}">{{event.name}}</a></div> <div class="col"><a href="{{ event.get_absolute_url }}">{{event.name}}</a></div>
<div class="col">{{ event.place }}</div> <div class="col">{{ event.place }}</div>
<div class="col">{{ event.start_time }}</div> <div class="col">{{ event.local_start_time }}</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -11,6 +11,7 @@
<img class="align-bottom" border="1" src="{{user.avatar_url}}" height="128px"/> {{user.user}} <img class="align-bottom" border="1" src="{{user.avatar_url}}" height="128px"/> {{user.user}}
{% if user.user.id == request.user.id %} {% if user.user.id == request.user.id %}
<a href="{% url 'edit-profile' %}" class="btn btn-secondary btn-sm">Edit Profile</a> <a href="{% url 'edit-profile' %}" class="btn btn-secondary btn-sm">Edit Profile</a>
<a href="{% url 'user-event-ical' request.user.profile.secret_key %}" class="btn btn-success btn-sm">iCal</a>
{% endif %} {% endif %}
</div> </div>

View file

@ -20,6 +20,7 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from events import views as event_views from events import views as event_views
from events import feeds
from . import views from . import views
urlpatterns = [ urlpatterns = [
@ -46,6 +47,7 @@ urlpatterns = [
path('profile/+confirm_email/<str:confirmation_key>', views.user_confirm_email, name='confirm-email'), path('profile/+confirm_email/<str:confirmation_key>', views.user_confirm_email, name='confirm-email'),
path('profile/+confirm_notifications', views.user_confirm_notifications, name='confirm-notifications'), path('profile/+confirm_notifications', views.user_confirm_notifications, name='confirm-notifications'),
path('profile/<int:user_id>/', views.show_profile, name='show-profile'), path('profile/<int:user_id>/', views.show_profile, name='show-profile'),
path('profile/<str:account_secret>.ics', feeds.UserEventsCalendar(), name='user-event-ical'),
path('events/', views.events_list, name='events'), path('events/', views.events_list, name='events'),
path('events/all/', views.events_list_all, name='all-events'), path('events/all/', views.events_list_all, name='all-events'),
@ -56,6 +58,7 @@ urlpatterns = [
path('team/<int:team_id>/+join/', event_views.join_team, name='join-team'), path('team/<int:team_id>/+join/', event_views.join_team, name='join-team'),
path('team/<int:team_id>/+leave/', event_views.leave_team, name='leave-team'), path('team/<int:team_id>/+leave/', event_views.leave_team, name='leave-team'),
path('team/<int:team_id>/+delete/', views.delete_team, name='delete-team'), path('team/<int:team_id>/+delete/', views.delete_team, name='delete-team'),
path('team/<int:team_id>/events.ics', feeds.TeamEventsCalendar(), name='team-event-ical'),
path('+create-team/', views.create_team, name='create-team'), path('+create-team/', views.create_team, name='create-team'),
path('team/+create-event/', views.create_event_team_select, name='create-event-team-select'), path('team/+create-event/', views.create_event_team_select, name='create-event-team-select'),

View file

@ -10,6 +10,7 @@ from events.models.events import Event, Place, Attendee
from events.models.profiles import Team, UserProfile, Member from events.models.profiles import Team, UserProfile, Member
from events.models.search import Searchable from events.models.search import Searchable
from events.forms import SearchForm from events.forms import SearchForm
from events import location
from accounts.decorators import setup_wanted from accounts.decorators import setup_wanted
from django.conf import settings from django.conf import settings
@ -69,7 +70,7 @@ def home(request, *args, **kwards):
if len(nearby_cities) == 0: if len(nearby_cities) == 0:
city_distance += 1 city_distance += 1
else: else:
city = nearby_cities[0] city = sorted(nearby_cities, key=lambda city: location.city_distance_from(ll, city))[0]
except: except:
pass # City lookup failed pass # City lookup failed
@ -93,10 +94,10 @@ def home(request, *args, **kwards):
context['maxlng'] = maxlng context['maxlng'] = maxlng
near_events = Searchable.objects.filter(latitude__gte=minlat, latitude__lte=maxlat, longitude__gte=minlng, longitude__lte=maxlng, end_time__gte=datetime.datetime.now()) near_events = Searchable.objects.filter(latitude__gte=minlat, latitude__lte=maxlat, longitude__gte=minlng, longitude__lte=maxlng, end_time__gte=datetime.datetime.now())
context['near_events'] = near_events context['near_events'] = sorted(near_events, key=lambda searchable: location.searchable_distance_from(ll, searchable))
near_teams = Team.objects.filter(city__latitude__gte=minlat, city__latitude__lte=maxlat, city__longitude__gte=minlng, city__longitude__lte=maxlng) near_teams = Team.objects.filter(city__latitude__gte=minlat, city__latitude__lte=maxlat, city__longitude__gte=minlng, city__longitude__lte=maxlng)
context['near_teams'] = near_teams context['near_teams'] = sorted(near_teams, key=lambda team: location.team_distance_from(ll, team))
except Exception as err: except Exception as err:
print("Error looking up nearby teams and events", err) print("Error looking up nearby teams and events", err)
traceback.print_exc() traceback.print_exc()

View file

@ -5,11 +5,12 @@ from django.contrib.auth import logout as logout_user
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect, reverse from django.shortcuts import render, redirect, reverse
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.utils import timezone
from events.models.profiles import Team, Organization, UserProfile, Member
from events.forms import TeamEventForm, NewTeamEventForm, DeleteEventForm, EventCommentForm, NewPlaceForm, UploadEventPhotoForm, NewCommonEventForm
from events.models.events import Event, CommonEvent, EventPhoto, Place, Attendee from events.models.events import Event, CommonEvent, 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 import location
import datetime import datetime
import simplejson import simplejson
@ -18,18 +19,20 @@ import simplejson
def events_list(request, *args, **kwargs): def events_list(request, *args, **kwargs):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return redirect('all-events') return redirect('all-events')
events = Event.objects.filter(attendees=request.user.profile, end_time__gt=datetime.datetime.now()).order_by('start_time') events = Event.objects.filter(attendees=request.user.profile, end_time__gt=timezone.now()).order_by('start_time')
geo_ip = location.get_geoip(request)
context = { context = {
'active': 'my', 'active': 'my',
'events_list': events, 'events_list': sorted(events, key=lambda event: location.event_distance_from(geo_ip.latlng, event)),
} }
return render(request, 'get_together/events/list_events.html', context) return render(request, 'get_together/events/list_events.html', context)
def events_list_all(request, *args, **kwargs): def events_list_all(request, *args, **kwargs):
events = Event.objects.filter(end_time__gt=datetime.datetime.now()).order_by('start_time') events = Event.objects.filter(end_time__gt=timezone.now()).order_by('start_time')
geo_ip = location.get_geoip(request)
context = { context = {
'active': 'all', 'active': 'all',
'events_list': events, 'events_list': sorted(events, key=lambda event: location.event_distance_from(geo_ip.latlng, event)),
} }
return render(request, 'get_together/events/list_events.html', context) return render(request, 'get_together/events/list_events.html', context)
@ -81,6 +84,7 @@ def create_event(request, team_id):
form = NewTeamEventForm(request.POST, instance=new_event) form = NewTeamEventForm(request.POST, instance=new_event)
if form.is_valid: if form.is_valid:
new_event = form.save() new_event = form.save()
Attendee.objects.create(event=new_event, user=request.user.profile, role=Attendee.HOST, status=Attendee.YES)
return redirect('add-place', new_event.id) return redirect('add-place', new_event.id)
else: else:
context = { context = {

View file

@ -7,9 +7,9 @@ from django.shortcuts import render, redirect
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from events.models.profiles import Organization, Team, UserProfile, Member from events.models.profiles import Organization, Team, UserProfile, Member
from events.forms import TeamForm, NewTeamForm, DeleteTeamForm
from events.models.events import Event, CommonEvent, Place, Attendee from events.models.events import Event, CommonEvent, Place, Attendee
from events.forms import TeamForm, NewTeamForm, DeleteTeamForm
from events import location
import datetime import datetime
import simplejson import simplejson
@ -19,18 +19,20 @@ def teams_list(request, *args, **kwargs):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return redirect('all-teams') return redirect('all-teams')
teams = request.user.profile.memberships.all() teams = request.user.profile.memberships.all().distinct()
geo_ip = location.get_geoip(request)
context = { context = {
'active': 'my', 'active': 'my',
'teams': teams, 'teams': sorted(teams, key=lambda team: location.team_distance_from(geo_ip.latlng, team)),
} }
return render(request, 'get_together/teams/list_teams.html', context) return render(request, 'get_together/teams/list_teams.html', context)
def teams_list_all(request, *args, **kwargs): def teams_list_all(request, *args, **kwargs):
teams = Team.objects.all() teams = Team.objects.all()
geo_ip = location.get_geoip(request)
context = { context = {
'active': 'all', 'active': 'all',
'teams': teams, 'teams': sorted(teams, key=lambda team: location.team_distance_from(geo_ip.latlng, team)),
} }
return render(request, 'get_together/teams/list_teams.html', context) return render(request, 'get_together/teams/list_teams.html', context)

View file

@ -6,6 +6,7 @@ defusedxml==0.5.0
dj-database-url==0.4.2 dj-database-url==0.4.2
Django==2.0 Django==2.0
django-appconf==1.0.2 django-appconf==1.0.2
django-ical==1.4
django-imagekit==4.0.2 django-imagekit==4.0.2
django-imagekit-cropper==1.16 django-imagekit-cropper==1.16
django-js-asset==1.0.0 django-js-asset==1.0.0
@ -14,6 +15,7 @@ django-settings-export==1.2.1
djangorestframework==3.7.7 djangorestframework==3.7.7
future==0.16.0 future==0.16.0
geocoder==1.36.0 geocoder==1.36.0
icalendar==4.0.1
idna==2.6 idna==2.6
Markdown==2.6.11 Markdown==2.6.11
model-mommy==1.5.1 model-mommy==1.5.1
@ -21,6 +23,7 @@ oauthlib==2.0.6
pilkit==2.0 pilkit==2.0
Pillow==5.0.0 Pillow==5.0.0
PyJWT==1.5.3 PyJWT==1.5.3
python-dateutil==2.7.2
python3-openid==3.1.0 python3-openid==3.1.0
pytz==2017.3 pytz==2017.3
ratelim==0.1.6 ratelim==0.1.6