From 5f16e176f9f1f7b8f74c8bec9268f9c3973032f0 Mon Sep 17 00:00:00 2001 From: Michael Hall Date: Tue, 24 Apr 2018 10:22:16 -0400 Subject: [PATCH] Add user management of their speaker bios and talks --- events/admin.py | 16 +- events/forms.py | 31 ++- events/migrations/0028_add_speaker_models.py | 15 +- events/models/events.py | 15 +- events/models/profiles.py | 30 ++- .../get_together/events/show_event.html | 10 + .../get_together/speakers/create_speaker.html | 20 ++ .../get_together/speakers/create_talk.html | 31 +++ .../get_together/speakers/delete_talk.html | 20 ++ .../get_together/speakers/edit_speaker.html | 46 ++++ .../get_together/speakers/edit_talk.html | 32 +++ .../speakers/list_user_presentations.html | 64 ++++++ .../speakers/list_user_talks.html | 57 +++++ .../get_together/users/show_profile.html | 37 +++- get_together/urls.py | 13 ++ get_together/views/__init__.py | 1 + get_together/views/events.py | 2 +- get_together/views/speakers.py | 200 ++++++++++++++++++ 18 files changed, 625 insertions(+), 15 deletions(-) create mode 100644 get_together/templates/get_together/speakers/create_speaker.html create mode 100644 get_together/templates/get_together/speakers/create_talk.html create mode 100644 get_together/templates/get_together/speakers/delete_talk.html create mode 100644 get_together/templates/get_together/speakers/edit_speaker.html create mode 100644 get_together/templates/get_together/speakers/edit_talk.html create mode 100644 get_together/templates/get_together/speakers/list_user_presentations.html create mode 100644 get_together/templates/get_together/speakers/list_user_talks.html create mode 100644 get_together/views/speakers.py diff --git a/events/admin.py b/events/admin.py index 01da08d..97e95f9 100644 --- a/events/admin.py +++ b/events/admin.py @@ -133,7 +133,17 @@ class TopicAdmin(admin.ModelAdmin): exclude = ('slug', ) admin.site.register(Topic, TopicAdmin) -admin.site.register(Speaker) -admin.site.register(Talk) -admin.site.register(Presentation) +class SpeakerAdmin(admin.ModelAdmin): + list_display = ('title', 'user', 'avatar') +admin.site.register(Speaker, SpeakerAdmin) + +class TalkAdmin(admin.ModelAdmin): + list_display = ('title', 'speaker', 'category') + list_filter = ('category',) +admin.site.register(Talk, TalkAdmin) + +class PresentationAdmin(admin.ModelAdmin): + list_display = ('talk', 'status', 'event') + list_filter = ('status',) +admin.site.register(Presentation, PresentationAdmin) diff --git a/events/forms.py b/events/forms.py index 7dba7a0..b16a66a 100644 --- a/events/forms.py +++ b/events/forms.py @@ -6,8 +6,17 @@ 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, EventSeries, Place, EventPhoto +from .models.profiles import Team, UserProfile, Speaker, Talk +from .models.events import ( + Event, + EventComment, + CommonEvent, + EventSeries, + Place, + EventPhoto, + Presentation, + SpeakerRequest, +) import recurrence import pytz @@ -329,3 +338,21 @@ class NewCommonEventForm(forms.ModelForm): 'end_time': DateTimeWidget } +class SpeakerBioForm(forms.ModelForm): + class Meta: + model = Speaker + fields = ['avatar', 'title', 'bio', 'categories'] + +class UserTalkForm(forms.ModelForm): + class Meta: + model = Talk + fields = ['speaker', 'title', 'abstract', 'talk_type', 'web_url', 'category'] + +class DeleteTalkForm(forms.Form): + confirm = forms.BooleanField(label="Yes, delete series", required=True) + +class SchedulePresentationForm(forms.ModelForm): + class Meta: + model = Presentation + fields = ['start_time'] + diff --git a/events/migrations/0028_add_speaker_models.py b/events/migrations/0028_add_speaker_models.py index 34d4625..a71d4b6 100644 --- a/events/migrations/0028_add_speaker_models.py +++ b/events/migrations/0028_add_speaker_models.py @@ -1,7 +1,9 @@ -# Generated by Django 2.0 on 2018-04-21 14:26 +# Generated by Django 2.0 on 2018-04-22 21:23 from django.db import migrations, models import django.db.models.deletion +import django.utils.timezone +import imagekit.models.fields class Migration(migrations.Migration): @@ -15,14 +17,19 @@ class Migration(migrations.Migration): name='Presentation', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), - ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.Event')), + ('status', models.SmallIntegerField(choices=[(-1, 'Declined'), (0, 'Proposed'), (1, 'Accepted')], db_index=True, default=0)), + ('start_time', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Start Time')), + ('created_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='events.UserProfile')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='presentations', to='events.Event')), ], ), migrations.CreateModel( name='Speaker', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('avatar', imagekit.models.fields.ProcessedImageField(blank=True, upload_to='avatars', verbose_name='Photo Image')), + ('title', models.CharField(blank=True, max_length=256, null=True)), ('bio', models.TextField(blank=True)), ], ), @@ -108,6 +115,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='presentation', name='talk', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='events.Talk'), + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='presentations', to='events.Talk'), ), ] diff --git a/events/models/events.py b/events/models/events.py index 335ff77..47aba50 100644 --- a/events/models/events.py +++ b/events/models/events.py @@ -381,9 +381,22 @@ class SpeakerRequest(models.Model): topics = models.ManyToManyField('Topic', blank=True) class Presentation(models.Model): + DECLINED=-1 + PROPOSED=0 + ACCEPTED=1 + + STATUSES = [ + (DECLINED, _("Declined")), + (PROPOSED, _("Proposed")), + (ACCEPTED, _("Accepted")), + ] event = models.ForeignKey(Event, related_name='presentations', on_delete=models.CASCADE) talk = models.ForeignKey(Talk, related_name='presentations', on_delete=models.SET_NULL, blank=False, null=True) - start_time = models.DateTimeField(verbose_name=_('Start Time'), db_index=True) + status = models.SmallIntegerField(choices=STATUSES, default=PROPOSED, db_index=True) + start_time = models.DateTimeField(verbose_name=_('Start Time'), db_index=True, null=True, blank=True) + + created_by = models.ForeignKey(UserProfile, on_delete=models.SET_NULL, null=True, blank=False) + created_time = models.DateTimeField(default=timezone.now, db_index=True) def __str__(self): return '%s at %s' % (self.talk.title, self.event.name) diff --git a/events/models/profiles.py b/events/models/profiles.py index e595701..2a8bb67 100644 --- a/events/models/profiles.py +++ b/events/models/profiles.py @@ -86,6 +86,10 @@ class UserProfile(models.Model): def moderating(self): return [member.team for member in Member.objects.filter(user=self, role__in=(Member.ADMIN, Member.MODERATOR))] + @property + def talks(self): + return Talk.objects.filter(speaker__user=self) + def can_create_event(self, team): try: if self.user.is_superuser: @@ -304,12 +308,26 @@ class Topic(models.Model): class Speaker(models.Model): user = models.ForeignKey(UserProfile, on_delete=models.CASCADE) - bio = models.TextField(blank=True) + avatar = ProcessedImageField(verbose_name=_("Photo Image"), + upload_to='avatars', + processors=[ResizeToFill(128, 128)], + format='PNG', + blank=True) + title = models.CharField(max_length=256, blank=True, null=True) + bio = models.TextField(verbose_name=_('Biography'), blank=True) categories = models.ManyToManyField('Category', blank=True) topics = models.ManyToManyField('Topic', blank=True) + def headshot(self): + if self.avatar: + return self.avatar + else: + return self.user.avatar + def __str__(self): + if self.title: + return self.title return self.user.__str__() class Talk(models.Model): @@ -327,7 +345,7 @@ class Talk(models.Model): (QANDA, _("Q & A")), (DEMO, _("Demonstration")), ] - speaker = models.ForeignKey(Speaker, on_delete=models.CASCADE) + speaker = models.ForeignKey(Speaker, verbose_name=_('Speaker Bio'), on_delete=models.CASCADE) title = models.CharField(max_length=256) abstract = models.TextField() talk_type = models.SmallIntegerField(_("Type"), choices=TYPES, default=PRESENTATION) @@ -336,6 +354,14 @@ class Talk(models.Model): category = models.ForeignKey('Category', on_delete=models.SET_NULL, blank=False, null=True) topics = models.ManyToManyField('Topic', blank=True) + @property + def future_presentations(self): + return self.presentations.filter(status__gte=0, event__start_time__gt=timezone.now()) + + @property + def past_presentations(self): + return self.presentations.filter(status=1, event__start_time__lte=timezone.now()) + def __str__(self): return self.title diff --git a/get_together/templates/get_together/events/show_event.html b/get_together/templates/get_together/events/show_event.html index e9a32ee..5ab01e7 100644 --- a/get_together/templates/get_together/events/show_event.html +++ b/get_together/templates/get_together/events/show_event.html @@ -94,6 +94,7 @@ {% endif %} @@ -146,6 +147,15 @@ {% endif %} + + Presentations: + + {% for presentation in presentation_list %} +
{{presentation.talk.title}} by {{presentation.talk.speaker}}
+ {% endfor %} + Propose a talk + +
diff --git a/get_together/templates/get_together/speakers/create_speaker.html b/get_together/templates/get_together/speakers/create_speaker.html new file mode 100644 index 0000000..b833928 --- /dev/null +++ b/get_together/templates/get_together/speakers/create_speaker.html @@ -0,0 +1,20 @@ +{% extends "get_together/base.html" %} +{% load static %} + + +{% block content %} +

New Speaker

+ + +
+{% csrf_token %} +
+ + + {{speaker_form}} +
+
+ +
+
+{% endblock %} diff --git a/get_together/templates/get_together/speakers/create_talk.html b/get_together/templates/get_together/speakers/create_talk.html new file mode 100644 index 0000000..1bd3b39 --- /dev/null +++ b/get_together/templates/get_together/speakers/create_talk.html @@ -0,0 +1,31 @@ +{% extends "get_together/base.html" %} +{% load static %} + + +{% block content %} +

New Talk

+ + +
+{% csrf_token %} +
+ + {{talk_form}} +
+
+ +
+
+{% endblock %} + + +{% block javascript %} + +{% endblock %} diff --git a/get_together/templates/get_together/speakers/delete_talk.html b/get_together/templates/get_together/speakers/delete_talk.html new file mode 100644 index 0000000..c716bb8 --- /dev/null +++ b/get_together/templates/get_together/speakers/delete_talk.html @@ -0,0 +1,20 @@ +{% extends "get_together/base.html" %} + +{% block content %} +

Confirm deletion

+{% if talk.future_presentations.count > 0 %} +
+
This talk has {{ talk.future_presentations.count }} pending event!
+
+{% endif %} +Are you sure you want to delete {{talk.title}}? +
+{% csrf_token %} +
+{{ delete_form }} +
+ +
+
+{% endblock %} + diff --git a/get_together/templates/get_together/speakers/edit_speaker.html b/get_together/templates/get_together/speakers/edit_speaker.html new file mode 100644 index 0000000..b28e2f4 --- /dev/null +++ b/get_together/templates/get_together/speakers/edit_speaker.html @@ -0,0 +1,46 @@ +{% extends "get_together/base.html" %} +{% load static %} + + +{% block content %} +

Edit Speaker

+ + +
+{% csrf_token %} +
+ + + {{speaker_form}} +
+
+ +
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/get_together/templates/get_together/speakers/edit_talk.html b/get_together/templates/get_together/speakers/edit_talk.html new file mode 100644 index 0000000..5a314c0 --- /dev/null +++ b/get_together/templates/get_together/speakers/edit_talk.html @@ -0,0 +1,32 @@ +{% extends "get_together/base.html" %} +{% load static %} + + +{% block content %} +

Edit Talk

+ + +
+{% csrf_token %} +
+ + {{talk_form}} +
+
+ +
+
+Delete + +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/get_together/templates/get_together/speakers/list_user_presentations.html b/get_together/templates/get_together/speakers/list_user_presentations.html new file mode 100644 index 0000000..5ed864a --- /dev/null +++ b/get_together/templates/get_together/speakers/list_user_presentations.html @@ -0,0 +1,64 @@ +{% extends "get_together/base.html" %} +{% load static tz %} + +{% block styles %} + + +{% endblock %} + +{% block content %} +
+{% if proposed_talks %} +
+{% for presentation in proposed_talks %} +
+
+
+

+ {{presentation.talk.title}} + {% if presentation.status == -1 %} + Declined + {% elif presentation.status == 1 %} + Accepted + {% else %} + Submitted + {% endif %} +

+
+ {{ presentation.talk.speaker }} +
+
+
+
+{% endfor %} +
+
+{% endif %} +
+{% for talk in available_talks %} +
+ {% csrf_token %} +
+
+
+

{{talk.title}}

+
+ {{ talk.speaker }} +
+
+
+
+
+{% endfor %} +
+
+ +
+
+ +{% endblock %} + diff --git a/get_together/templates/get_together/speakers/list_user_talks.html b/get_together/templates/get_together/speakers/list_user_talks.html new file mode 100644 index 0000000..dd7e26b --- /dev/null +++ b/get_together/templates/get_together/speakers/list_user_talks.html @@ -0,0 +1,57 @@ +{% extends "get_together/base.html" %} +{% load static tz %} + +{% block styles %} + + +{% endblock %} + +{% block content %} +
+
+{% for speaker in speaker_bios %} +
+
+
+
+

+ {{speaker.title}} +

+
+ {{ speaker.bio }} +
+
+
+
+{% endfor %} +
+
+ +
+
+ +
+{% for talk in talks %} +
+
+
+

{{talk.title}}

+
+ {{ talk.abstract }} +
+
+
+
+{% endfor %} +
+
+
+ Talk +
+
+
+ +{% endblock %} + diff --git a/get_together/templates/get_together/users/show_profile.html b/get_together/templates/get_together/users/show_profile.html index c36cfcf..ff99f78 100644 --- a/get_together/templates/get_together/users/show_profile.html +++ b/get_together/templates/get_together/users/show_profile.html @@ -1,4 +1,10 @@ {% extends "get_together/base.html" %} +{% load static %} + +{% block styles %} + + +{% endblock %} {% block content %} @@ -6,11 +12,19 @@
-
+
{{user.user}} {% if user.user.id == request.user.id %} - Edit Profile + iCal {% endif %} @@ -21,6 +35,25 @@ {% if user.weburl %}

Homepage: {{user.weburl}}

{% endif %} +{% if user.talks %} +

Talks

+
+
+ {% for talk in user.talks %} +
+
+
+

{{talk.title}}

+
+ {{ talk.speaker }} +
+
+
+
+ {% endfor %} +
+
+{% endif %}
{% if teams %} diff --git a/get_together/urls.py b/get_together/urls.py index 1766586..6bb8510 100644 --- a/get_together/urls.py +++ b/get_together/urls.py @@ -49,6 +49,16 @@ urlpatterns = [ path('profile//', views.show_profile, name='show-profile'), path('profile/.ics', feeds.UserEventsCalendar(), name='user-event-ical'), + path('profile/+add-speaker', views.add_speaker, name='add-speaker'), + path('speaker//+edit', views.edit_speaker, name='edit-speaker'), + path('speaker//+delete', views.delete_speaker, name='delete-speaker'), + + path('profile/+talks', views.list_user_talks, name='user-talks'), + path('profile/+add-talk', views.add_talk, name='add-talk'), + path('talk//', views.show_talk, name='show-talk'), + path('talk//+edit', views.edit_talk, name='edit-talk'), + path('talk//+delete', views.delete_talk, name='delete-talk'), + path('events/', views.events_list, name='events'), path('events/all/', views.events_list_all, name='all-events'), path('teams/', views.teams_list, name='teams'), @@ -70,7 +80,10 @@ urlpatterns = [ path('events//+add_place/', views.add_place_to_event, name='add-place'), path('events//+comment/', event_views.comment_event, name='comment-event'), path('events//+photo/', views.add_event_photo, name='add-event-photo'), + path('events//+propose-talk/', views.propose_event_talk, name='propose-event-talk'), + path('events//+schedule-talks/', views.schedule_event_talks, name='schedule-event-talks'), path('events///', views.show_event, name='show-event'), + path('series//+edit/', views.edit_series, name='edit-series'), path('series//+delete/', views.delete_series, name='delete-series'), path('series//+add_place/', views.add_place_to_series, name='add-place-to-series'), diff --git a/get_together/views/__init__.py b/get_together/views/__init__.py index 9c5e8fe..1410be3 100644 --- a/get_together/views/__init__.py +++ b/get_together/views/__init__.py @@ -27,6 +27,7 @@ from .places import * from .user import * from .new_user import * from .new_team import * +from .speakers import * from .utils import * KM_PER_DEGREE_LAT = 110.574 diff --git a/get_together/views/events.py b/get_together/views/events.py index 26c5da5..0e31d5c 100644 --- a/get_together/views/events.py +++ b/get_together/views/events.py @@ -65,7 +65,7 @@ def show_event(request, event_id, event_slug): 'comment_form': comment_form, 'is_attending': request.user.profile in event.attendees.all(), 'attendee_list': Attendee.objects.filter(event=event), - 'presentation_list': event.presentations.all().order_by('start_time'), + 'presentation_list': event.presentations.filter(status=Presentation.ACCEPTED).order_by('start_time'), 'can_edit_event': request.user.profile.can_edit_event(event), } return render(request, 'get_together/events/show_event.html', context) diff --git a/get_together/views/speakers.py b/get_together/views/speakers.py new file mode 100644 index 0000000..c1318a0 --- /dev/null +++ b/get_together/views/speakers.py @@ -0,0 +1,200 @@ +from django.utils.translation import ugettext_lazy as _ + +from django.contrib import messages +from django.contrib.auth import logout as logout_user +from django.shortcuts import render, redirect, get_object_or_404 +from django.http import HttpResponse, JsonResponse +from django.core.exceptions import ObjectDoesNotExist + +from events.models.profiles import UserProfile, Speaker, Talk +from events.forms import ( + SpeakerBioForm, + UserTalkForm, + DeleteTalkForm, + SchedulePresentationForm, +) + +from events.models.events import Event, Presentation + +import datetime +import simplejson + +from .teams import * +from .events import * + +@login_required +def list_user_talks(request): + profile = request.user.profile + speaker_bios = Speaker.objects.filter(user=profile) + talks = list(Talk.objects.filter(speaker__user=profile)) + context = { + 'speaker_bios': speaker_bios, + 'talks': talks, + } + return render(request, 'get_together/speakers/list_user_talks.html', context) + +def add_speaker(request): + new_speaker = Speaker(user=request.user.profile) + if request.method == 'GET': + speaker_form = SpeakerBioForm(instance=new_speaker) + context = { + 'speaker': new_speaker, + 'speaker_form': speaker_form, + } + return render(request, 'get_together/speakers/create_speaker.html', context) + elif request.method == 'POST': + speaker_form = SpeakerBioForm(request.POST, request.FILES, instance=new_speaker) + if speaker_form.is_valid(): + new_speaker = speaker_form.save() + return redirect('show-talks') + else: + context = { + 'speaker': new_speaker, + 'speaker_form': speaker_form, + } + return render(request, 'get_together/speakers/create_speaker.html', context) + return redirect('home') + +def edit_speaker(request, speaker_id): + speaker = get_object_or_404(Speaker, id=speaker_id) + if request.method == 'GET': + speaker_form = SpeakerBioForm(instance=speaker) + context = { + 'speaker': speaker, + 'speaker_form': speaker_form, + } + return render(request, 'get_together/speakers/edit_speaker.html', context) + elif request.method == 'POST': + speaker_form = SpeakerBioForm(request.POST, request.FILES, instance=speaker) + if speaker_form.is_valid(): + speaker = speaker_form.save() + return redirect('user-talks') + else: + context = { + 'speaker': speaker, + 'speaker_form': speaker_form, + } + return render(request, 'get_together/speakers/edit_speaker.html', context) + return redirect('home') + +def delete_speaker(request, speaker_id): + pass + +def show_talk(request, talk_id): + pass + +def add_talk(request): + new_talk = Talk() + if request.method == 'GET': + talk_form = UserTalkForm(instance=new_talk) + talk_form.fields['speaker'].queryset = request.user.profile.speaker_set + context = { + 'talk': new_talk, + 'talk_form': talk_form, + } + return render(request, 'get_together/speakers/create_talk.html', context) + elif request.method == 'POST': + talk_form = UserTalkForm(request.POST, instance=new_talk) + talk_form.fields['speaker'].queryset = request.user.profile.speaker_set + if talk_form.is_valid(): + new_talk = talk_form.save() + return redirect('user-talks') + else: + context = { + 'talk': new_talk, + 'talk_form': talk_form, + } + return render(request, 'get_together/speakers/create_talk.html', context) + return redirect('home') + +def edit_talk(request, talk_id): + talk = get_object_or_404(Talk, id=talk_id) + if not talk.speaker.user == request.user.profile: + messages.add_message(request, messages.WARNING, message=_('You can not make changes to this talk.')) + return redirect('show-talk', talk_id) + + if request.method == 'GET': + talk_form = UserTalkForm(instance=talk) + talk_form.fields['speaker'].queryset = request.user.profile.speaker_set + context = { + 'talk': talk, + 'talk_form': talk_form, + } + return render(request, 'get_together/speakers/edit_talk.html', context) + elif request.method == 'POST': + talk_form = UserTalkForm(request.POST, instance=talk) + talk_form.fields['speaker'].queryset = request.user.profile.speaker_set + if talk_form.is_valid(): + talk = talk_form.save() + return redirect('user-talks') + else: + context = { + 'talk': talk, + 'talk_form': talk_form, + } + return render(request, 'get_together/speakers/edit_talk.html', context) + return redirect('home') + +def delete_talk(request, talk_id): + talk = get_object_or_404(Talk, id=talk_id) + if not talk.speaker.user == request.user.profile: + messages.add_message(request, messages.WARNING, message=_('You can not make changes to this talk.')) + return redirect('show-talk', talk_id) + + if request.method == 'GET': + form = DeleteTalkForm() + + context = { + 'talk': talk, + 'delete_form': form, + } + return render(request, 'get_together/speakers/delete_talk.html', context) + elif request.method == 'POST': + form = DeleteTalkForm(request.POST) + if form.is_valid() and form.cleaned_data['confirm']: + talk.delete() + return redirect('user-talks') + else: + context = { + 'talk': talk, + 'delete_form': form, + } + return render(request, 'get_together/speakers/delete_talk.html', context) + else: + return redirect('home') + +@login_required +def propose_event_talk(request, event_id): + event = get_object_or_404(Event, id=event_id) + if request.method == 'GET': + profile = request.user.profile + talks = list(Talk.objects.filter(speaker__user=profile)) + presentations = event.presentations.all().order_by('-status') + for presentation in presentations: + if presentation.talk in talks: + talks.remove(presentation.talk) + + context = { + 'event': event, + 'available_talks': talks, + 'proposed_talks': presentations, + } + return render(request, 'get_together/speakers/list_user_presentations.html', context) + elif request.method == 'POST': + talk = get_object_or_404(Talk, id=request.POST.get('talk_id')) + new_proposal = Presentation.objects.create( + event=event, + talk=talk, + status=Presentation.PROPOSED, + start_time=event.local_start_time, + created_by=request.user.profile, + ) + messages.add_message(request, messages.SUCCESS, message=_('Your talk has been submitted to the event organizer.')) + return redirect(event.get_absolute_url()) + else: + redirect('home') + +def schedule_event_talks(request, event_id): + pass + +