From 86fcf7ed710f41fc5324b638d092af54f4bb756f Mon Sep 17 00:00:00 2001 From: luxagraf Date: Thu, 11 Apr 2019 19:46:12 -0500 Subject: initial commit --- .gitignore | 16 ++ app/blog/__init__.py | 0 app/blog/admin.py | 46 ++++ app/blog/build.py | 22 ++ app/blog/migrations/0001_initial.py | 43 ++++ app/blog/migrations/__init__.py | 0 app/blog/models.py | 70 ++++++ app/blog/urls.py | 28 +++ app/blog/views.py | 47 ++++ app/builder/__init__.py | 0 app/builder/base.py | 152 +++++++++++++ app/builder/sanitizer.py | 60 +++++ app/builder/views.py | 13 ++ app/pages/__init__.py | 0 app/pages/admin.py | 32 +++ app/pages/build.py | 39 ++++ app/pages/migrations/0001_initial.py | 26 +++ app/pages/migrations/0002_page_build.py | 18 ++ app/pages/migrations/__init__.py | 0 app/pages/models.py | 42 ++++ app/pages/tests/__init__.py | 0 app/pages/tests/test_models.py | 36 +++ app/pages/tests/test_views.py | 27 +++ app/pages/views.py | 18 ++ app/photos/__init__.py | 0 app/photos/admin.py | 32 +++ app/photos/detail_urls.py | 10 + app/photos/forms.py | 173 +++++++++++++++ app/photos/migrations/0001_initial.py | 73 +++++++ app/photos/migrations/__init__.py | 0 app/photos/models.py | 223 +++++++++++++++++++ app/photos/photos.js | 71 ++++++ app/photos/retriever.py.bak | 314 +++++++++++++++++++++++++++ app/photos/static/image-preview.js | 42 ++++ app/photos/static/my_styles.css | 40 ++++ app/photos/templatetags/__init__.py | 0 app/photos/templatetags/get_image_by_size.py | 8 + app/photos/templatetags/get_image_width.py | 9 + app/photos/urls.py | 56 +++++ app/photos/utils.py | 28 +++ app/photos/views.py | 59 +++++ app/taxonomy/migrations/0001_initial.py | 52 +++++ app/taxonomy/migrations/__init__.py | 0 app/taxonomy/models.py | 39 ++++ app/utils/__init__.py | 0 app/utils/forms.py | 7 + app/utils/next_prev.py | 80 +++++++ app/utils/static/autocomplete.js | 10 + app/utils/static/choices.css | 2 + app/utils/static/choices.min.js | 5 + app/utils/static/image-loader.js | 47 ++++ app/utils/static/next-prev-links.js | 60 +++++ app/utils/urls.py | 12 + app/utils/util.py | 118 ++++++++++ app/utils/views.py | 102 +++++++++ app/utils/widgets.py | 153 +++++++++++++ config/__init__.py | 0 config/base_urls.py | 35 +++ config/django.ini | 30 +++ config/requirements.txt | 41 ++++ config/wsgi.py | 26 +++ design/config.rb | 12 + design/sass/_content.scss | 236 ++++++++++++++++++++ design/sass/_fonts.scss | 35 +++ design/sass/_footer.scss | 54 +++++ design/sass/_global.scss | 243 +++++++++++++++++++++ design/sass/_header.scss | 135 ++++++++++++ design/sass/_mixins.scss | 107 +++++++++ design/sass/_queries.scss | 23 ++ design/sass/screenv1.scss | 7 + design/templates/admin/buttons.html | 52 +++++ design/templates/admin/index.html | 148 +++++++++++++ design/templates/admin/message.html | 20 ++ design/templates/base.html | 91 ++++++++ design/templates/homepage.html | 88 ++++++++ manage.py | 18 ++ 76 files changed, 3961 insertions(+) create mode 100644 .gitignore create mode 100644 app/blog/__init__.py create mode 100644 app/blog/admin.py create mode 100644 app/blog/build.py create mode 100644 app/blog/migrations/0001_initial.py create mode 100644 app/blog/migrations/__init__.py create mode 100644 app/blog/models.py create mode 100644 app/blog/urls.py create mode 100644 app/blog/views.py create mode 100644 app/builder/__init__.py create mode 100644 app/builder/base.py create mode 100644 app/builder/sanitizer.py create mode 100644 app/builder/views.py create mode 100644 app/pages/__init__.py create mode 100644 app/pages/admin.py create mode 100644 app/pages/build.py create mode 100644 app/pages/migrations/0001_initial.py create mode 100644 app/pages/migrations/0002_page_build.py create mode 100644 app/pages/migrations/__init__.py create mode 100644 app/pages/models.py create mode 100644 app/pages/tests/__init__.py create mode 100644 app/pages/tests/test_models.py create mode 100644 app/pages/tests/test_views.py create mode 100644 app/pages/views.py create mode 100644 app/photos/__init__.py create mode 100644 app/photos/admin.py create mode 100644 app/photos/detail_urls.py create mode 100644 app/photos/forms.py create mode 100644 app/photos/migrations/0001_initial.py create mode 100644 app/photos/migrations/__init__.py create mode 100644 app/photos/models.py create mode 100644 app/photos/photos.js create mode 100644 app/photos/retriever.py.bak create mode 100644 app/photos/static/image-preview.js create mode 100644 app/photos/static/my_styles.css create mode 100644 app/photos/templatetags/__init__.py create mode 100644 app/photos/templatetags/get_image_by_size.py create mode 100644 app/photos/templatetags/get_image_width.py create mode 100644 app/photos/urls.py create mode 100644 app/photos/utils.py create mode 100644 app/photos/views.py create mode 100644 app/taxonomy/migrations/0001_initial.py create mode 100644 app/taxonomy/migrations/__init__.py create mode 100644 app/taxonomy/models.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/forms.py create mode 100644 app/utils/next_prev.py create mode 100644 app/utils/static/autocomplete.js create mode 100644 app/utils/static/choices.css create mode 100644 app/utils/static/choices.min.js create mode 100644 app/utils/static/image-loader.js create mode 100644 app/utils/static/next-prev-links.js create mode 100644 app/utils/urls.py create mode 100644 app/utils/util.py create mode 100644 app/utils/views.py create mode 100644 app/utils/widgets.py create mode 100644 config/__init__.py create mode 100644 config/base_urls.py create mode 100644 config/django.ini create mode 100644 config/requirements.txt create mode 100644 config/wsgi.py create mode 100644 design/config.rb create mode 100644 design/sass/_content.scss create mode 100644 design/sass/_fonts.scss create mode 100644 design/sass/_footer.scss create mode 100644 design/sass/_global.scss create mode 100644 design/sass/_header.scss create mode 100644 design/sass/_mixins.scss create mode 100644 design/sass/_queries.scss create mode 100644 design/sass/screenv1.scss create mode 100644 design/templates/admin/buttons.html create mode 100644 design/templates/admin/index.html create mode 100644 design/templates/admin/message.html create mode 100644 design/templates/base.html create mode 100644 design/templates/homepage.html create mode 100755 manage.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b61590 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.sass-cache +*~ +.Trashes +Icon? +ehthumbs.db +Thumbs.db +*.pyc +venv/ +site/ +/static/ +config/settings.py +bak/ +/*.json +.vagrant/ +.env +*.log diff --git a/app/blog/__init__.py b/app/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blog/admin.py b/app/blog/admin.py new file mode 100644 index 0000000..6a4749f --- /dev/null +++ b/app/blog/admin.py @@ -0,0 +1,46 @@ +from django.contrib import admin + +from utils.widgets import LGEntryForm + +from .models import Entry + + +@admin.register(Entry) +class EntryAdmin(admin.ModelAdmin): + form = LGEntryForm + list_display = ('title', 'pub_date', 'enable_comments', 'status') + list_filter = ('pub_date', 'enable_comments', 'status') + prepopulated_fields = {"slug": ('title',)} + fieldsets = ( + ('Entry', { + 'fields': ( + 'title', + 'sub_title', + 'body_markdown', + ('pub_date', 'status'), + 'meta_description', + ('slug', 'enable_comments', 'has_code'), + ), + 'classes': ( + 'show', + 'extrapretty', + 'wide' + ) + }), + ('meta', { + 'fields': ( + 'originally_published_by', + 'originally_published_by_url', + 'afterword', + ('field_notes', 'books'), + ), + 'classes': ( + 'hide', + 'extrapretty', + 'wide' + ) + }), + ) + + class Media: + js = ('image-loader.js', 'next-prev-links.js') diff --git a/app/blog/build.py b/app/blog/build.py new file mode 100644 index 0000000..392e991 --- /dev/null +++ b/app/blog/build.py @@ -0,0 +1,22 @@ +import os +from builder.base import BuildNew +from django.urls import reverse +from . import models + + +class BuildEssays(BuildNew): + + def build(self): + self.build_list_view() + self.build_detail_view() + # These are the unique classes for this model: + #self.build_feed("src:feed") + + def build_list_view(self): + response = self.client.get('/essays/') + self.write_file('essays/', response.content) + + +def essaybuilder(): + j = BuildEssays("essays", "essay") + j.build() diff --git a/app/blog/migrations/0001_initial.py b/app/blog/migrations/0001_initial.py new file mode 100644 index 0000000..be54329 --- /dev/null +++ b/app/blog/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 2.1.7 on 2019-03-30 17:07 + +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('photos', '0001_initial'), + ('taxonomy', '__first__'), + ] + + operations = [ + migrations.CreateModel( + name='Entry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('sub_title', models.CharField(blank=True, max_length=200)), + ('dek', models.TextField(blank=True)), + ('slug', models.SlugField(unique_for_date='pub_date')), + ('body_html', models.TextField(blank=True)), + ('body_markdown', models.TextField()), + ('pub_date', models.DateTimeField(verbose_name='Date published')), + ('last_updated', models.DateTimeField(auto_now=True)), + ('enable_comments', models.BooleanField(default=False)), + ('status', models.IntegerField(choices=[(0, 'Draft'), (1, 'Published')], default=0)), + ('meta_description', models.CharField(blank=True, max_length=256, null=True)), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='taxonomy.Category')), + ('featured_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='photos.LuxImage')), + ('tags', taggit.managers.TaggableManager(blank=True, help_text='Topics Covered', through='taxonomy.TaggedItems', to='taxonomy.LuxTag', verbose_name='Tags')), + ], + options={ + 'get_latest_by': 'pub_date', + 'verbose_name_plural': 'Essays', + 'ordering': ('-pub_date',), + }, + ), + ] diff --git a/app/blog/migrations/__init__.py b/app/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blog/models.py b/app/blog/models.py new file mode 100644 index 0000000..3d1ae30 --- /dev/null +++ b/app/blog/models.py @@ -0,0 +1,70 @@ +from django.db import models +from django.urls import reverse +from django.contrib.sitemaps import Sitemap +import datetime + +from taggit.managers import TaggableManager + +from taxonomy.models import TaggedItems, Category +from utils.util import render_images, markdown_to_html +from photos.models import LuxImage + + +class Entry(models.Model): + title = models.CharField(max_length=200) + sub_title = models.CharField(max_length=200, blank=True) + dek = models.TextField(blank=True) + slug = models.SlugField(unique_for_date='pub_date') + body_html = models.TextField(blank=True) + body_markdown = models.TextField() + pub_date = models.DateTimeField('Date published') + last_updated = models.DateTimeField(auto_now=True) + enable_comments = models.BooleanField(default=False) + PUB_STATUS = ( + (0, 'Draft'), + (1, 'Published'), + ) + status = models.IntegerField(choices=PUB_STATUS, default=0) + meta_description = models.CharField(max_length=256, null=True, blank=True) + tags = TaggableManager(through=TaggedItems, blank=True, help_text='Topics Covered') + featured_image = models.ForeignKey(LuxImage, on_delete=models.CASCADE, null=True, blank=True) + category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True) + + class Meta: + ordering = ('-pub_date',) + get_latest_by = 'pub_date' + verbose_name_plural = 'Essays' + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse('essays:detail', kwargs={"slug": self.slug}) + + def comment_period_open(self): + return self.enable_comments and datetime.datetime.today() - datetime.timedelta(30) <= self.pub_date + + @property + def get_previous_published(self): + return self.get_previous_by_pub_date(status__exact=1) + + @property + def get_next_published(self): + return self.get_next_by_pub_date(status__exact=1) + + def save(self): + md = render_images(self.body_markdown) + self.body_html = markdown_to_html(md) + super(Entry, self).save() + + +class GuideSitemap(Sitemap): + changefreq = "never" + priority = 1.0 + protocol = "https" + + def items(self): + return Essay.objects.filter(status=1) + + def lastmod(self, obj): + return obj.pub_date diff --git a/app/blog/urls.py b/app/blog/urls.py new file mode 100644 index 0000000..da3e1fd --- /dev/null +++ b/app/blog/urls.py @@ -0,0 +1,28 @@ +from django.urls import path, re_path + +from . import views + +app_name = "blog" + +urlpatterns = [ + #path( + # r'topic/', + # views.TopicListView.as_view(), + # name="list_topics" + #), + path( + r'', + views.EntryDetailView.as_view(), + name="detail" + ), + path( + r'', + views.EntryDetailViewTXT.as_view(), + name="detail-txt" + ), + path( + r'', + views.EntryListView.as_view(), + name="list", + ), +] diff --git a/app/blog/views.py b/app/blog/views.py new file mode 100644 index 0000000..56bd823 --- /dev/null +++ b/app/blog/views.py @@ -0,0 +1,47 @@ +from django.views.generic import ListView +from django.views.generic.detail import DetailView +from django.contrib.syndication.views import Feed + + +from .models import Entry + + +class EntryListView(ListView): + model = Entry + + def get_queryset(self, **kwargs): + qs = Entry.objects.filter(status=1) + return qs + + +class EntryDetailView(DetailView): + model = Entry + + +class EntryDetailViewTXT(EntryDetailView): + template_name = "entry_detail.txt" + + +''' +class TopicListView(ListView): + template_name = 'archives/src_home.html' + + def queryset(self): + return Post.objects.filter(topics__slug=self.kwargs['slug']) + + def get_context_data(self, **kwargs): + # Call the base implementation first to get a context + context = super(TopicListView, self).get_context_data(**kwargs) + context['topic'] = Topic.objects.get(slug__exact=self.kwargs['slug']) + return context + + +class SrcRSSFeedView(Feed): + title = "luxagraf:src Code and Technology" + link = "/src/" + description = "Latest postings to luxagraf.net/src" + description_template = 'feeds/blog_description.html' + + def items(self): + return Post.objects.filter(status__exact=1).order_by('-pub_date')[:10] +''' diff --git a/app/builder/__init__.py b/app/builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/builder/base.py b/app/builder/base.py new file mode 100644 index 0000000..0d2cb0f --- /dev/null +++ b/app/builder/base.py @@ -0,0 +1,152 @@ +import os +from math import ceil +from decimal import Decimal +from django.test.client import Client +from django.template.loader import render_to_string +from django.template import Context +from django.urls import reverse +from django.apps import apps +from django.conf import settings +from jsmin import jsmin + + +class _FileWriter(object): + """ + Given a path and text object; write the page to disc + """ + def __init__(self, path, text_object, ext='html', filename='index', base_path=settings.FLATFILES_ROOT): + self.path = '%s%s' % (base_path, path) + if not os.path.isdir(self.path): + os.makedirs(self.path) + fpath = '%s%s.%s' % (self.path, filename, ext) + self.write(fpath, text_object) + + def write(self, fpath, text_object): + f = open(fpath, 'wb') + f.write(text_object) + f.close() + + def compress_js(self, filename, text_object): + path = '%s%s.min.js' % (self.path, filename) + compressed = jsmin(text_object.decode('utf-8')).encode('utf-8') + self.write(path, compressed) + + +class BuildNew(): + def __init__(self, model, app): + self.model = apps.get_model(model, app) + self.get_model_queryset() + self.client = Client() + + def build(self): + self.build_list_view() + self.build_detail_view() + + def get_model_queryset(self): + return self.model.objects.filter(status__exact=1) + + def write_file(self, path, text_object, ext='html', filename='index'): + self.writer = _FileWriter(path, text_object, ext=ext, filename=filename) + + def get_pages(self, qs, paginate_by): + return int(ceil(Decimal(qs.count()) / Decimal(paginate_by))) + + def build_list_view(self, base_path='', qs=None, paginate_by=10): + """ + Archive Page builder that actually crawls the urls + because we need to be able to pass a request object to the template + """ + + if not qs: + qs = self.get_model_queryset() + pages = self.get_pages(qs, paginate_by) + for page in range(pages): + if int(pages) > 1: + path = '%s%s/' % (base_path, str(page + 1)) + url = '%s%s/' % (base_path, str(page + 1)) + else: + path = base_path + url = base_path + print(path) + response = self.client.get(url, HTTP_HOST='127.0.0.1', follow=True) + if page == 0: + self.write_file(base_path, response.content) + self.write_file(path, response.content) + + def build_year_view(self, url, paginate_by=99999): + years = self.model.objects.dates('pub_date', 'year') + for year in years: + year = year.strftime('%Y') + qs = self.model.objects.filter( + status__exact=1, + pub_date__year=year + ) + self.build_list_view( + base_path=reverse(url, kwargs={'year': year, }), + qs=qs, + paginate_by=paginate_by + ) + + def build_month_view(self, url, paginate_by=99999): + months = self.model.objects.dates('pub_date', 'month') + for m in months: + year = m.strftime('%Y') + month = m.strftime('%m') + qs = self.model.objects.filter( + status__exact=1, + pub_date__year=year, + pub_date__month=month + ) + if qs.exists(): + self.build_list_view( + base_path=reverse(url, kwargs={'year': year, 'month': month}), + qs=qs, + paginate_by=paginate_by + ) + + def build_detail_view(self): + ''' + Grab all the blog posts, render them to a template + string and write that out to the filesystem + ''' + for entry in self.get_model_queryset(): + url = entry.get_absolute_url() + path, slug = os.path.split(entry.get_absolute_url()) + path = '%s/' % path + # write html + response = self.client.get(url) + self.write_file(path, response.content, filename=slug) + # write txt + response = self.client.get('%s.txt' % url) + self.write_file(path, response.content, ext='txt', filename=slug) + + + def build_feed(self, url_name): + """ + Not called, but available for subclassing + """ + url = reverse(url_name,) + path, slug = os.path.split(url) + slug, ext = os.path.splitext(slug) + response = self.client.get(url, HTTP_HOST='127.0.0.1') + self.write_file('%s/' % path, response.content, ext=ext.split(".")[-1], filename=slug) + + +class BuildSitemap(BuildNew): + def build(self): + c = Client() + response = c.get('/sitemap.xml', HTTP_HOST='127.0.0.1') + self.write_file('', response.content, 'xml', 'sitemap') + + +class BuildPages(BuildNew): + def build(self): + model = apps.get_model('pages', 'page') + pages = model.objects.all() + for page in pages: + c = Context({'object':page,'SITE_URL':settings.SITE_URL, 'MEDIA_URL':settings.BAKED_MEDIA_URL}) + t = render_to_string(["details/%s.html" % page.slug, 'details/page.html'],c).encode('utf-8') + s = render_to_string('details/page.txt',c).encode('utf-8') + fpath = '%s' %(page.slug) + self.write_file('', t, 'html', page.slug) + self.write_file('', t, 'txt', page.slug) diff --git a/app/builder/sanitizer.py b/app/builder/sanitizer.py new file mode 100644 index 0000000..8512f4f --- /dev/null +++ b/app/builder/sanitizer.py @@ -0,0 +1,60 @@ +from bs4 import BeautifulSoup + + +class Sanitizer(object): + blacklisted_tags = [] + blacklisted_attributes = [] + blacklisted_protocols = [] + + def __init__(self, tags=None, attributes=None, protocols=None): + if tags: + self.blacklisted_tags = tags + if attributes: + self.blacklisted_attributes = attributes + if protocols: + self.blacklisted_protocols = protocols + + def strip(self, content=None): + """Strip HTML content to meet standards of output type. + Meant to be subclassed for each converter. + + Keyword arguments: + content -- subset of an HTML document. (ie. contents of a body tag) + """ + if not content: + content = self.content + return content + + soup = BeautifulSoup(content, "lxml") + self.strip_tags(soup) + self.strip_attributes(soup) + + output = soup.body.decode_contents() + return output + + def strip_tags(self, soup): + if self.blacklisted_tags: + [x.extract() for x in soup.find_all(self.blacklisted_tags)] + + def strip_attributes_extra(self, node): + pass + + def strip_attributes(self, soup): + if not (self.blacklisted_attributes or self.blacklisted_protocols): + return + + for node in soup.body.find_all(True): + attributes = node.attrs.keys() + if not attributes: + continue + + for attr in self.blacklisted_attributes: + if attr in attributes: + del node.attrs[attr] + + self.strip_attributes_extra(node) + + if 'href' in attributes: + protocol = node['href'].split(':')[0] + if protocol in self.blacklisted_protocols: + del node['href'] \ No newline at end of file diff --git a/app/builder/views.py b/app/builder/views.py new file mode 100644 index 0000000..9d12aaa --- /dev/null +++ b/app/builder/views.py @@ -0,0 +1,13 @@ +from django.shortcuts import render_to_response +from django.template import RequestContext +#from src.build import builder as src_builder +from pages.build import builder as page_builder + + +def do_build(request): + section = request.GET.get('id', '') + context = {} + if section == 'pages': + context = {'message': 'Writing Pages to Disk'} + page_builder() + return render_to_response('admin/message.html', context) diff --git a/app/pages/__init__.py b/app/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/pages/admin.py b/app/pages/admin.py new file mode 100644 index 0000000..706d690 --- /dev/null +++ b/app/pages/admin.py @@ -0,0 +1,32 @@ +from django.contrib import admin + +from django import forms + +from pages.models import Page + + +class PageEntryForm(forms.ModelForm): + class Meta: + model = Page + fields = '__all__' + widgets = { + 'body_markdown': forms.Textarea(attrs={'rows': 50, 'cols': 100}), + } + + +@admin.register(Page) +class PageAdmin(admin.ModelAdmin): + form = PageEntryForm + list_display = ('title', 'slug', 'path') + search_fields = ['title', 'body_markdown'] + prepopulated_fields = {"slug": ('title',)} + fieldsets = ( + ('Page', { + 'fields': ('title', 'body_markdown', ('slug', 'path', )), + 'classes': ('show', 'extrapretty', 'wide') + }), + ('Metadata', { + 'classes': ('collapse closed',), + 'fields': ('meta_description',), + }) + ) diff --git a/app/pages/build.py b/app/pages/build.py new file mode 100644 index 0000000..75dbd0d --- /dev/null +++ b/app/pages/build.py @@ -0,0 +1,39 @@ +import os +from django.template.loader import render_to_string +from django.template import Context +from django.urls import reverse +from django.conf import settings + +from builder.base import BuildNew + + +class BuildPages(BuildNew): + def build(self): + self.build_detail_view() + print("building pages") + + def build_detail_view(self): + ''' + Grab all the blog posts, render them to a template + string and write that out to the filesystem + ''' + for entry in self.get_model_queryset(): + url = entry.get_absolute_url() + path, slug = os.path.split(entry.get_absolute_url()) + path = '%s/' % path + # write html + response = self.client.get(url) + if slug == 'homepage': + slug = 'index' + self.write_file(path, response.content, filename=slug) + # write txt + response = self.client.get('%s.txt' % url) + self.write_file(path, response.content, ext='txt', filename=slug) + + def get_model_queryset(self): + return self.model.objects.filter(build=True) + + +def builder(): + j = BuildPages("pages", "Page") + j.build() diff --git a/app/pages/migrations/0001_initial.py b/app/pages/migrations/0001_initial.py new file mode 100644 index 0000000..49e763f --- /dev/null +++ b/app/pages/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 2.1.2 on 2018-11-11 21:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Page', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('slug', models.SlugField()), + ('body_html', models.TextField(blank=True)), + ('body_markdown', models.TextField()), + ('meta_description', models.CharField(blank=True, max_length=256, null=True)), + ('path', models.CharField(blank=True, max_length=200, null=True)), + ], + ), + ] diff --git a/app/pages/migrations/0002_page_build.py b/app/pages/migrations/0002_page_build.py new file mode 100644 index 0000000..6dd9fc0 --- /dev/null +++ b/app/pages/migrations/0002_page_build.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.7 on 2019-04-11 23:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='page', + name='build', + field=models.BooleanField(default=True), + ), + ] diff --git a/app/pages/migrations/__init__.py b/app/pages/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/pages/models.py b/app/pages/models.py new file mode 100644 index 0000000..1c9ab23 --- /dev/null +++ b/app/pages/models.py @@ -0,0 +1,42 @@ +from django.db import models +from django.template.defaultfilters import slugify +from django.contrib.sitemaps import Sitemap + +from utils.util import markdown_to_html, render_images + + +class Page(models.Model): + title = models.CharField(max_length=200) + slug = models.SlugField() + body_html = models.TextField(blank=True) + body_markdown = models.TextField() + meta_description = models.CharField(max_length=256, null=True, blank=True) + path = models.CharField(max_length=200, null=True, blank=True) + build = models.BooleanField(default=True) + + def __str__(self): + return self.title + + def get_absolute_url(self): + if self.path: + return "/%s/%s" % (self.path, self.slug) + else: + return "/%s" % (self.slug) + + def save(self): + # run markdown + md = render_images(self.body_markdown) + self.body_html = markdown_to_html(md) + if not self.id: + # self.date_created = timezone.now() + self.slug = slugify(self.title)[:50] + super(Page, self).save() + + +class PageSitemap(Sitemap): + changefreq = "never" + priority = 1.0 + protocol = "https" + + def items(self): + return Page.objects.all() diff --git a/app/pages/tests/__init__.py b/app/pages/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/pages/tests/test_models.py b/app/pages/tests/test_models.py new file mode 100644 index 0000000..2722430 --- /dev/null +++ b/app/pages/tests/test_models.py @@ -0,0 +1,36 @@ +from django.test import TestCase + +from pages.models import Page + + +class PageModelTest(TestCase): + def setUp(self): + self.page = Page( + title="Test Page", + meta_description="The meta desc", + body_markdown="the body of the page", + ) + self.page.save() + self.pathpage = Page( + title="Test Page", + meta_description="The meta desc", + body_markdown="the body of the page", + path="test-path", + ) + self.pathpage.save() + + def test_string_representation(self): + self.assertEqual(str(self.page), "Test Page") + self.assertEqual(str(self.page.slug), "test-page") + self.assertEqual(str(self.page.body_markdown), "the body of the page") + self.assertEqual(str(self.page.body_html), "

the body of the page

") + self.assertEqual(str(self.page.meta_description), "The meta desc") + self.assertEqual(self.page.path, None) + + def test_get_absolute_url(self): + """Absolute URL should return /page """ + self.assertEqual(str(self.page.get_absolute_url()), "/test-page") + + def test_path_get_absolute_url(self): + """Absolute URL with a path should return /path/page """ + self.assertEqual(str(self.pathpage.get_absolute_url()), "/test-path/test-page") diff --git a/app/pages/tests/test_views.py b/app/pages/tests/test_views.py new file mode 100644 index 0000000..c771a29 --- /dev/null +++ b/app/pages/tests/test_views.py @@ -0,0 +1,27 @@ +from django.test import RequestFactory, TestCase +from django.contrib import auth + +from pages.models import Page + +User = auth.get_user_model() + + +class PageViewTest(TestCase): + def setUp(self): + # Every test needs access to the request factory. + self.factory = RequestFactory() + self.page = Page( + title="Test Page", + meta_description="The meta desc", + body_markdown="the body of the page", + ) + self.page.save() + + def test_non_existent_page(self): + """A non-existent staticflatpage raises a 404.""" + response = self.client.get('/no_such_page/') + self.assertEqual(response.status_code, 404) + + def test_detail_view(self): + response = self.client.get(self.page.get_absolute_url()) + self.assertEqual(response.status_code, 200) diff --git a/app/pages/views.py b/app/pages/views.py new file mode 100644 index 0000000..41288dd --- /dev/null +++ b/app/pages/views.py @@ -0,0 +1,18 @@ +from django.views.generic.detail import DetailView +from django.contrib.auth.forms import AuthenticationForm +from pages.models import Page + + +class PageDetailView(DetailView): + model = Page + slug_field = "slug" + + def get_template_names(self): + obj = self.get_object() + return ["%s.html" % obj.slug, "pages/%s.html" % obj.slug, 'pages/page.html'] + + +class HomePageDetailView(PageDetailView): + + def get_object(self): + return Page.objects.get(slug='homepage') diff --git a/app/photos/__init__.py b/app/photos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/photos/admin.py b/app/photos/admin.py new file mode 100644 index 0000000..072808c --- /dev/null +++ b/app/photos/admin.py @@ -0,0 +1,32 @@ +from django.contrib import admin +from photos.models import LuxImage, LuxImageSize, LuxVideo + + +@admin.register(LuxImageSize) +class LuxImageSizeAdmin(admin.ModelAdmin): + list_display = ('name', 'width', 'height', 'quality') + + +@admin.register(LuxVideo) +class LuxVideoAdmin(admin.ModelAdmin): + pass + + +@admin.register(LuxImage) +class LuxImageAdmin(admin.ModelAdmin): + list_display = ('pk', 'admin_thumbnail', 'pub_date', 'caption') + list_filter = ('pub_date',) + search_fields = ['title', 'caption'] + + fieldsets = ( + (None, { + 'fields': ('title', ('image'), 'pub_date', 'sizes', 'alt', 'caption', ('is_public'), ('photo_credit_source', 'photo_credit_url')) + }), + ('Exif Data', { + 'classes': ('collapse',), + 'fields': ('height', 'width'), + }), + ) + + class Media: + js = ('image-preview.js', 'next-prev-links.js') diff --git a/app/photos/detail_urls.py b/app/photos/detail_urls.py new file mode 100644 index 0000000..0ab94f6 --- /dev/null +++ b/app/photos/detail_urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url +from django.views.generic.detail import DetailView +from photos.models import Photo + +urlpatterns = [ + url( + r'^(?P\d+)/$', + DetailView.as_view(model=Photo, template_name='details/photo.html') + ), +] diff --git a/app/photos/forms.py b/app/photos/forms.py new file mode 100644 index 0000000..126cfaf --- /dev/null +++ b/app/photos/forms.py @@ -0,0 +1,173 @@ +import zipfile +from zipfile import BadZipFile +import logging +import datetime +import os +from io import BytesIO +try: + import Image +except ImportError: + from PIL import Image + +from django import forms +from django.utils.translation import ugettext_lazy as _ +from django.contrib import messages +from django.core.files.base import ContentFile +from django.contrib.admin import widgets +from django.utils.safestring import mark_safe + +from photos.models import LuxImage, LuxGallery, LuxImageSize + +logger = logging.getLogger('photos.forms') + + +class GalleryForm(forms.ModelForm): + class Meta: + fields = '__all__' + widgets = { + 'images': forms.SelectMultiple, + } + + def __init__(self, *args, **kwargs): + super(GalleryForm, self).__init__(*args, **kwargs) + self.fields['images'].choices = [(image.id, mark_safe('%sqq%sqq%s' % (image.title, image.get_image_by_size('tn'), image.pk))) for image in LuxImage.objects.all()[:40]] + self.fields['images'].allow_tags = True + + +class ImageChoiceField(forms.ModelMultipleChoiceField): + + def label_from_instance(self, obj): + + return mark_safe('%sqq%sqq%s' % (obj.title, obj.get_image_by_size('tn'), obj.pk)) + + +class FKGalleryForm(forms.ModelForm): + class Meta: + fields = '__all__' + widgets = { + 'image': ImageChoiceField(queryset=LuxImage.objects.all()), + } + + def __init__(self, *args, **kwargs): + super(FKGalleryForm, self).__init__(*args, **kwargs) + self.fields['image'].choices = [(o.id, str(o.image.url)) for o in LuxImage.objects.all()] + self.fields['image'].allow_tags = True + + +class UploadZipForm(forms.Form): + """ + Handles the uploading of a gallery of photos packed in a .zip file + Creates Gallery object, adds photos with all metadata that's available + """ + zip_file = forms.FileField() + title = forms.CharField(label=_('Gallery Title'), max_length=250) + slug = forms.SlugField(label=_('Gallery Slug')) + desc = forms.CharField(label=_('Gallery Caption'), widget=forms.Textarea, required=False) + date = forms.SplitDateTimeField(label=_('Date'), widget=widgets.AdminSplitDateTime) + is_public = forms.BooleanField(label=_('Is public'), initial=True, required=False, help_text=_('Show on site')) + + def clean_zip_file(self): + """Open the zip file a first time, to check that it is a valid zip archive. + We'll open it again in a moment, so we have some duplication, but let's focus + on keeping the code easier to read! + """ + zip_file = self.cleaned_data['zip_file'] + try: + zip = zipfile.ZipFile(zip_file) + except BadZipFile as e: + raise forms.ValidationError(str(e)) + bad_file = zip.testzip() + if bad_file: + zip.close() + raise forms.ValidationError('"%s" in the .zip archive is corrupt.' % bad_file) + zip.close() # Close file in all cases. + return zip_file + + def clean_title(self): + title = self.cleaned_data['title'] + if title and LuxGallery.objects.filter(title=title).exists(): + raise forms.ValidationError(_('A gallery with that title already exists.')) + return title + + def clean(self): + cleaned_data = super(UploadZipForm, self).clean() + if not self['title'].errors: + # If there's already an error in the title, no need to add another + # error related to the same field. + if not cleaned_data.get('title', None) and not cleaned_data['gallery']: + raise forms.ValidationError( + _('Select an existing gallery, or enter a title for a new gallery.')) + return cleaned_data + + def save(self, request=None, zip_file=None): + if not zip_file: + zip_file = self.cleaned_data['zip_file'] + + gallery, created = LuxGallery.objects.get_or_create( + title=self.cleaned_data['title'], + description=self.cleaned_data['desc'], + slug=self.cleaned_data['slug'], + pub_date=self.cleaned_data['date'], + is_public=self.cleaned_data['is_public'] + ) + zipper = zipfile.ZipFile(zip_file) + count = 1 + for filename in sorted(zipper.namelist()): + f, file_extension = os.path.splitext(filename) + logger.debug('Reading file "{0}".'.format(filename)) + if filename.startswith('__') or filename.startswith('.'): + logger.debug('Ignoring file "{0}".'.format(filename)) + continue + if os.path.dirname(filename): + logger.warning('Ignoring file "{0}" as it is in a subfolder; all images should be in the top ' + 'folder of the zip.'.format(filename)) + if request: + messages.warning(request, + _('Ignoring file "{filename}" as it is in a subfolder').format(filename=filename), fail_silently=True) + continue + data = zipper.read(filename) + + if not len(data): + logger.debug('File "{0}" is empty.'.format(filename)) + continue + + fn, file_extension = os.path.splitext(filename) + if file_extension != ".mp4": + # Basic check that we have a valid image. + try: + file = BytesIO(data) + opened = Image.open(file) + opened.verify() + except Exception: + # Pillow (or PIL) doesn't recognize it as an image. + # If a "bad" file is found we just skip it. + # But we do flag this both in the logs and to the user. + logger.error('Could not process file "{0}" in the .zip archive.'.format(filename)) + if request: + messages.warning(request, + _('Could not process file "{0}" in the .zip archive.').format( + filename), + fail_silently=True) + continue + image = LuxImage( + pub_date=datetime.datetime.now() + ) + contentfile = ContentFile(data) + image.image.save(filename, contentfile) + if file_extension != ".mp4": + img = Image.open(image.image.path) + if img.size[0] > img.size[1]: + image.sizes.add(LuxImageSize.objects.get(width=2560)) + image.sizes.add(LuxImageSize.objects.get(width=1170)) + image.sizes.add(LuxImageSize.objects.get(width=720)) + if img.size[1] > img.size[0]: + image.sizes.add(LuxImageSize.objects.get(height=1600)) + image.sizes.add(LuxImageSize.objects.get(height=800)) + image.sizes.add(LuxImageSize.objects.get(height=460)) + image.save() + gallery.images.add(image) + + zipper.close() + + if request: + messages.success(request, _('The photos have been uploaded')) diff --git a/app/photos/migrations/0001_initial.py b/app/photos/migrations/0001_initial.py new file mode 100644 index 0000000..f06bad5 --- /dev/null +++ b/app/photos/migrations/0001_initial.py @@ -0,0 +1,73 @@ +# Generated by Django 2.1.7 on 2019-03-30 17:07 + +import datetime +from django.db import migrations, models +import photos.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='LuxImage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.FileField(blank=True, null=True, upload_to=photos.models.get_upload_path)), + ('title', models.CharField(blank=True, max_length=300, null=True)), + ('alt', models.CharField(blank=True, max_length=300, null=True)), + ('photo_credit_source', models.CharField(blank=True, max_length=300, null=True)), + ('photo_credit_url', models.CharField(blank=True, max_length=300, null=True)), + ('caption', models.TextField(blank=True, null=True)), + ('pub_date', models.DateTimeField(default=datetime.datetime.now)), + ('height', models.CharField(blank=True, max_length=6, null=True)), + ('width', models.CharField(blank=True, max_length=6, null=True)), + ('is_public', models.BooleanField(default=True)), + ], + options={ + 'get_latest_by': 'pub_date', + 'ordering': ('-pub_date', 'id'), + 'verbose_name_plural': 'Images', + }, + ), + migrations.CreateModel( + name='LuxImageSize', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=30, null=True)), + ('width', models.IntegerField(blank=True, null=True)), + ('height', models.IntegerField(blank=True, null=True)), + ('quality', models.IntegerField()), + ], + options={ + 'verbose_name_plural': 'Image Sizes', + }, + ), + migrations.CreateModel( + name='LuxVideo', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('video_mp4', models.FileField(blank=True, null=True, upload_to=photos.models.get_vid_upload_path)), + ('video_webm', models.FileField(blank=True, null=True, upload_to=photos.models.get_vid_upload_path)), + ('video_poster', models.FileField(blank=True, null=True, upload_to=photos.models.get_vid_upload_path)), + ('title', models.CharField(blank=True, max_length=300, null=True)), + ('pub_date', models.DateTimeField(default=datetime.datetime.now)), + ('youtube_url', models.CharField(blank=True, max_length=80, null=True)), + ('vimeo_url', models.CharField(blank=True, max_length=300, null=True)), + ], + options={ + 'get_latest_by': 'pub_date', + 'ordering': ('-pub_date', 'id'), + 'verbose_name_plural': 'Videos', + }, + ), + migrations.AddField( + model_name='luximage', + name='sizes', + field=models.ManyToManyField(blank=True, to='photos.LuxImageSize'), + ), + ] diff --git a/app/photos/migrations/__init__.py b/app/photos/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/photos/models.py b/app/photos/models.py new file mode 100644 index 0000000..4a1bf99 --- /dev/null +++ b/app/photos/models.py @@ -0,0 +1,223 @@ +import os.path +import io +import datetime +from PIL import Image + +from django.core.exceptions import ValidationError +from django.db import models +from django.contrib.sitemaps import Sitemap +from django.utils.encoding import force_text +from django.urls import reverse +from django.apps import apps +from django.utils.html import format_html +from django.utils.text import slugify +from django.conf import settings +from django import forms + +from resizeimage.imageexceptions import ImageSizeError + +from .utils import resize_image +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.db.models.signals import m2m_changed + + +def get_upload_path(self, filename): + return "images/original/%s/%s" % (datetime.datetime.today().strftime("%Y"), filename) + + +def get_vid_upload_path(self, filename): + return "images/videos/%s/%s" % (datetime.datetime.today().strftime("%Y"), filename) + + +class LuxImageSize(models.Model): + name = models.CharField(null=True, blank=True, max_length=30) + width = models.IntegerField(null=True, blank=True) + height = models.IntegerField(null=True, blank=True) + quality = models.IntegerField() + + class Meta: + verbose_name_plural = 'Image Sizes' + + def __str__(self): + if self.width: + size = self.width + if self.height: + size = self.height + return "%s - %s" %(self.name, str(size)) + + +class LuxImage(models.Model): + image = models.FileField(blank=True, null=True, upload_to=get_upload_path) + title = models.CharField(null=True, blank=True, max_length=300) + alt = models.CharField(null=True, blank=True, max_length=300) + photo_credit_source = models.CharField(null=True, blank=True, max_length=300) + photo_credit_url = models.CharField(null=True, blank=True, max_length=300) + caption = models.TextField(blank=True, null=True) + pub_date = models.DateTimeField(default=datetime.datetime.now) + height = models.CharField(max_length=6, blank=True, null=True) + width = models.CharField(max_length=6, blank=True, null=True) + is_public = models.BooleanField(default=True) + sizes = models.ManyToManyField(LuxImageSize, blank=True) + + class Meta: + ordering = ('-pub_date', 'id') + verbose_name_plural = 'Images' + get_latest_by = 'pub_date' + + def __str__(self): + if self.title: + return "%s" % self.title + else: + return "%s" % self.pk + + def get_type(self): + return str(self.__class__.__name__) + + def get_admin_image(self): + for size in self.sizes.all(): + if size.width and size.width <= 820 or size.height and size.height <= 800: + return self.get_image_by_size(size.name) + + def get_admin_insert(self): + return "/media/images/%s/%s_tn.%s" % (self.pub_date.strftime("%Y"), self.get_image_name(), self.get_image_ext()) + + def get_largest_image(self): + t = [] + for size in self.sizes.all(): + t.append(size.width) + t.sort(key=float) + t.reverse() + return self.get_image_path_by_size(t[0]) + + def get_image_name(self): + return self.image.url.split("original/")[1][5:-4] + + def get_image_ext(self): + return self.image.url[-3:] + + def get_image_by_size(self, size="original"): + base = self.get_image_name() + if size == "admin_insert": + return "images/%s/%s.%s" % (self.pub_date.strftime("%Y"), base, self.get_image_ext()) + if size == "original": + return "%soriginal/%s/%s.%s" % (settings.IMAGES_URL, self.pub_date.strftime("%Y"), base, self.get_image_ext()) + else: + if size != 'tn': + s = LuxImageSize.objects.get(name=size) + if s not in self.sizes.all(): + print("new size is "+s.name) + self.sizes.add(s) + return "%s%s/%s_%s.%s" % (settings.IMAGES_URL, self.pub_date.strftime("%Y"), base, size, self.get_image_ext()) + + def get_image_path_by_size(self, size="original"): + base = self.get_image_name() + if size == "original": + return "%s/original/%s/%s.%s" % (settings.IMAGES_ROOT, self.pub_date.strftime("%Y"), base, self.get_image_ext()) + else: + return "%s/%s/%s_%s.%s" % (settings.IMAGES_ROOT, self.pub_date.strftime("%Y"), base, size, self.get_image_ext()) + + def admin_thumbnail(self): + return format_html('' % (self.get_image_by_size(), self.get_image_by_size("tn"))) + admin_thumbnail.short_description = 'Thumbnail' + + @property + def get_previous_published(self): + return self.get_previous_by_pub_date() + + @property + def get_next_published(self): + return self.get_next_by_pub_date() + + @property + def get_previous_admin_url(self): + n = self.get_previous_by_pub_date() + return reverse('admin:%s_%s_change' %(self._meta.app_label, self._meta.model_name), args=[n.id] ) + + @property + def get_next_admin_url(self): + model = apps.get_model(app_label=self._meta.app_label, model_name=self._meta.model_name) + try: + return reverse('admin:%s_%s_change' %(self._meta.app_label, self._meta.model_name), args=[self.get_next_by_pub_date().pk] ) + except model.DoesNotExist: + return '' + + @property + def is_portait(self): + if int(self.height) > int(self.width): + return True + else: + return False + + def save(self, *args, **kwargs): + super(LuxImage, self).save() + + +@receiver(post_save, sender=LuxImage) +def post_save_events(sender, update_fields, created, instance, **kwargs): + if instance.exif_raw == '': + filename, file_extension = os.path.splitext(instance.image.path) + if file_extension != ".mp4": + img = Image.open(instance.image.path) + instance.height = img.height + instance.width = img.width + #instance = readexif(instance) + post_save.disconnect(post_save_events, sender=LuxImage) + instance.save() + post_save.connect(post_save_events, sender=LuxImage) + + +@receiver(m2m_changed, sender=LuxImage.sizes.through) +def update_photo_sizes(sender, instance, **kwargs): + base_path = "%s/%s/" % (settings.IMAGES_ROOT, instance.pub_date.strftime("%Y")) + filename, file_extension = os.path.splitext(instance.image.path) + if file_extension != ".mp4": + img = Image.open(instance.image.path) + resize_image(img, 160, None, 78, base_path, "%s_tn.%s" % (instance.get_image_name(), instance.get_image_ext())) + for size in instance.sizes.all(): + if size.width: + print("Image width is:"+str(img.width)) + try: + if size.width <= img.width: + resize_image(img, size.width, None, size.quality, base_path, "%s_%s.%s" % (instance.get_image_name(), slugify(size.name), instance.get_image_ext())) + else: + raise ValidationError({'items': ["Size is larger than source image"]}) + except ImageSizeError: + m2m_changed.disconnect(update_photo_sizes, sender=LuxImage.sizes.through) + instance.sizes.remove(size) + m2m_changed.connect(update_photo_sizes, sender=LuxImage.sizes.through) + if size.height: + try: + if size.height <= img.height: + resize_image(img, None, size.height, size.quality, base_path, "%s_%s.%s" % (instance.get_image_name(), slugify(size.name), instance.get_image_ext())) + + else: + pass + except ImageSizeError: + m2m_changed.disconnect(update_photo_sizes, sender=LuxImage.sizes.through) + instance.sizes.remove(size) + m2m_changed.connect(update_photo_sizes, sender=LuxImage.sizes.through) + + +class LuxVideo(models.Model): + video_mp4 = models.FileField(blank=True, null=True, upload_to=get_vid_upload_path) + video_webm = models.FileField(blank=True, null=True, upload_to=get_vid_upload_path) + video_poster = models.FileField(blank=True, null=True, upload_to=get_vid_upload_path) + title = models.CharField(null=True, blank=True, max_length=300) + pub_date = models.DateTimeField(default=datetime.datetime.now) + youtube_url = models.CharField(null=True, blank=True, max_length=80) + vimeo_url = models.CharField(null=True, blank=True, max_length=300) + + def __str__(self): + if self.title: + return self.title + else: + return str(self.pk) + + def get_type(self): + return str(self.__class__.__name__) + + class Meta: + ordering = ('-pub_date', 'id') + verbose_name_plural = 'Videos' + get_latest_by = 'pub_date' diff --git a/app/photos/photos.js b/app/photos/photos.js new file mode 100644 index 0000000..b93467a --- /dev/null +++ b/app/photos/photos.js @@ -0,0 +1,71 @@ +//Utility functions for map info window +function mapit(obj) { + lat = parseFloat(obj.attr('data-latitude')); + lon = parseFloat(obj.attr('data-longitude')); + elid= obj.attr('data-imgid'); + map = L.map(document.getElementById("mw-"+elid)); + centerCoord = new L.LatLng(lat, lon); + zoom = 8; + L.tileLayer.provider('Esri.WorldTopoMap', {maxZoom: 18, attribution: 'Map data © OpenStreetMap contributors, CC-BY-SA, Tiles © Esri and the GIS User Community'}).addTo(map); + map.setView(centerCoord, zoom); + L.marker([lat, lon]).addTo(map); +} + //########## utility functions to create/remove map container ############ +function create_map(obj) { + //find id of this image caption: + var imgid = obj.attr('data-imgid'); + //create container divs + $('
').insertBefore($(obj).parent().parent()); + //$(obj).parent().parent().parent().prepend('
'); + $('#mc-'+imgid).append('
'); + //deal with the variable height of div.legend + $('#mc-'+imgid).css({ + bottom: function(index, value) { + return parseFloat($(obj).parent().parent().height())+20; + } + }); + + mapit(obj); +} +function remove_map(imgid) { + $('#mc-'+imgid).remove(); +} + +//############ Document.ready events ############## +$(document).ready(function(){ + + //set up click events for map button + $('.map-link').click( function() { + imgid = $(this).attr('data-imgid'); + if ($('#mc-'+imgid).is(":visible")) { + remove_map(imgid); + } else { + create_map($(this)); + } + return false; + + }); + var $ele = $('#slides').children(); + var $curr = 0; + $(document).bind('keydown', function (e) { + var code = e.which; + switch (code) { + case 39: + if ($curr <= $ele.size()) { + $.scrollTo($ele[$curr], 800 ); + $curr++; + } + break; + case 37: + if ($curr > 0) { + $curr--; + var $now = $curr; + $now--; + $.scrollTo($ele[$now], 800 ); + } + break; + } + return; + }); +}); + diff --git a/app/photos/retriever.py.bak b/app/photos/retriever.py.bak new file mode 100644 index 0000000..d3c572a --- /dev/null +++ b/app/photos/retriever.py.bak @@ -0,0 +1,314 @@ +from __future__ import division +import datetime +import os +import cStringIO +import urllib + +from django.template.defaultfilters import slugify +from django.core.exceptions import ObjectDoesNotExist +from django.utils.encoding import force_unicode +from django.conf import settings + +# Required PIL classes may or may not be available from the root namespace +# depending on the installation +try: + import Image + import ImageFile +except ImportError: + try: + from PIL import Image + from PIL import ImageFile + except ImportError: + raise ImportError("Could not import the Python Imaging Library.") + +ImageFile.MAXBLOCK = 1000000 + +from photos.models import Photo, PhotoGallery + +# from https://github.com/alexis-mignon/python-flickr-api +# terribly documented, but offers a good clean OOP approach if you're willing to figure it out... +import flickr_api + +EXIF_PARAMS = { + "FNumber": 'f/2.8', + "Make": 'Apple', + "Model": 'iPhone', + "ExposureTime": '', + "ISO": '', + "FocalLength": '', + "LensModel": '', + 'DateTimeOriginal': '2013:09:03 22:44:25' +} + + +def sync_flickr_photos(*args, **kwargs): + flickr_api.set_keys(api_key=settings.FLICKR_API_KEY, api_secret=settings.FLICKR_API_SECRET) + flickr_api.set_auth_handler("app/photos/flickrauth") + user = flickr_api.test.login() + photos = user.getPhotos(extras="date_upload,date_taken,geo") + # reverse! reverse! + photos.reverse() + for photo in photos: + info = photo.getInfo() + try: + row = Photo.objects.get(flickr_id=info['id'], flickr_secret=info['secret']) + print('already have ' + info['id'] + ' moving on') + except ObjectDoesNotExist: + get_photo(photo) + + +def get_photo(photo): + info = photo.getInfo() + geo = photo.getLocation() + location, region = get_geo(float(geo['latitude']), float(geo['longitude'])) + exif = exif_handler(photo.getExif()) + p, created = Photo.objects.get_or_create( + title=info['title'], + flickr_id=info['id'], + flickr_owner=info['owner']['id'], + flickr_server=info['server'], + flickr_secret=info['secret'], + flickr_originalsecret=info['originalsecret'], + flickr_farm=info['farm'], + pub_date=flickr_datetime_to_datetime(info['taken']), + description=info['description'], + exif_aperture=exif['FNumber'], + exif_make=exif['Make'], + exif_model=exif['Model'], + exif_exposure=exif['ExposureTime'], + exif_iso=exif['ISO'], + exif_lens=exif['LensModel'], + exif_focal_length=exif['FocalLength'], + exif_date=flickr_datetime_to_datetime(exif["DateTimeOriginal"].replace(':', '-', 2)), + lat=float(geo['latitude']), + lon=float(geo['longitude']), + region=region, + location=location, + ) + if created: + for tag in info['tags']: + p.tags.add(tag['raw']) + p.save() + make_local_copies(p) + #retina image: + #slideshow_image(p, 2000, 1600, 75) + #normal image + print(p.title) + return p + + +def sync_sets(*args, **kwargs): + flickr_api.set_keys(api_key=settings.FLICKR_API_KEY, api_secret=settings.FLICKR_API_SECRET) + flickr_api.set_auth_handler("app/photos/flickrauth") + user = flickr_api.test.login() + photosets = user.getPhotosets() + # reverse! reverse! + photosets.reverse() + disregard = [ + 'POTD 2008', + 'Snow Day', + 'Wedding', + 'Some random stuff', + 'Lilah & Olivia', + '6 months+', + '6-9 months', + '9-18 months', + ] + for photoset in photosets: + if photoset['title'] in disregard: + pass + else: + try: + row = PhotoGallery.objects.get(set_id__exact=photoset['id']) + print('%s %s %s' % ('already have', row.set_title, 'moving on...')) + # okay it already exists, but is it up-to-date? + #get_photos_in_set(row,set.id) + except ObjectDoesNotExist: + s = PhotoGallery.objects.create( + set_id=force_unicode(photoset['id']), + set_title=force_unicode(photoset['title']), + set_desc=force_unicode(photoset['description']), + set_slug=slugify(force_unicode(photoset['title'])), + primary=force_unicode(photoset['primary']), + pub_date=datetime.datetime.fromtimestamp(float(photoset['date_create'])) + ) + + get_photos_in_set(photoset, s) + #create the gallery thumbnail image: + photo = Photo.objects.get(flickr_id__exact=str(photoset['primary'])) + make_gallery_thumb(photo, s) + + +def get_photos_in_set(flickr_photoset, photoset): + for photo in flickr_photoset.getPhotos(): + try: + p = Photo.objects.get(flickr_id__exact=str(photo['id'])) + except ObjectDoesNotExist: + p = get_photo(photo) + if p.is_public: + photoset.photos.add(p) + slideshow_image(p, 1000, 800, 95) + + +################################################ +## Various meta data and geo helper functions ## +################################################ + + +def exif_handler(data): + converted = {} + try: + for t in data: + converted[t['tag']] = t['raw'] + except: + pass + for k, v in EXIF_PARAMS.items(): + if not converted.has_key(k): + converted[k] = v + return converted + + +def flickr_datetime_to_datetime(fdt): + from datetime import datetime + from time import strptime + date_parts = strptime(fdt, '%Y-%m-%d %H:%M:%S') + return datetime(*date_parts[0:6]) + +def get_geo(lat,lon): + from locations.models import Location, Region + from django.contrib.gis.geos import Point + pnt_wkt = Point(lon, lat) + try: + location = Location.objects.get(geometry__contains=pnt_wkt) + except Location.DoesNotExist: + location = None + try: + region = Region.objects.get(geometry__contains=pnt_wkt) + except Region.DoesNotExist: + region = None + return location, region + +####################################################################### +## Photo retrieval functions to pull down images from Flickr servers ## +####################################################################### + +def slideshow_image(photo,max_width, max_height, quality): + slide_dir = settings.IMAGES_ROOT + '/slideshow/'+ photo.pub_date.strftime("%Y") + if not os.path.isdir(slide_dir): + os.makedirs(slide_dir) + + # Is it a retina image or not? + if max_width >= 1001 or max_height >= 801: + filename = '%s/%sx2.jpg' %(slide_dir, photo.flickr_id) + else: + filename = '%s/%s.jpg' %(slide_dir, photo.flickr_id) + + flickr_photo = photo.get_original_url() + fname = urllib.urlopen(flickr_photo) + im = cStringIO.StringIO(fname.read()) # constructs a StringIO holding the image + img = Image.open(im) + cur_width, cur_height = img.size + #if image landscape + if cur_width > cur_height: + new_width = max_width + #check to make sure we aren't upsizing + if cur_width > new_width: + ratio = float(new_width)/cur_width + x = (cur_width * ratio) + y = (cur_height * ratio) + resized = img.resize((int(x), int(y)), Image.ANTIALIAS) + resized.save(filename, 'JPEG', quality=quality, optimize=True) + else: + img.save(filename) + else: + #image portrait + new_height = max_height + #check to make sure we aren't upsizing + if cur_height > new_height: + ratio = float(new_height)/cur_height + x = (cur_width * ratio) + y = (cur_height * ratio) + resized = img.resize((int(x), int(y)), Image.ANTIALIAS) + resized.save(filename, 'JPEG', quality=quality, optimize=True) + else: + img.save(filename) + photo.slideshowimage_width = photo.get_width + photo.slideshowimage_height = photo.get_height + photo.slideshowimage_margintop = photo.get_margin_top + photo.slideshowimage_marginleft = photo.get_margin_left + photo.save() + #now resize the local copy + + + +def make_local_copies(photo): + orig_dir = settings.IMAGES_ROOT + '/flickr/full/'+ photo.pub_date.strftime("%Y") + if not os.path.isdir(orig_dir): + os.makedirs(orig_dir) + full = photo.get_original_url() + fname = urllib.urlopen(full) + im = cStringIO.StringIO(fname.read()) # constructs a StringIO holding the image + img = Image.open(im) + local_full = '%s/%s.jpg' %(orig_dir, photo.flickr_id) + img.save(local_full) + #save large size + large_dir = settings.IMAGES_ROOT + '/flickr/large/'+ photo.pub_date.strftime("%Y") + if not os.path.isdir(large_dir): + os.makedirs(large_dir) + large = photo.get_large_url() + fname = urllib.urlopen(large) + im = cStringIO.StringIO(fname.read()) # constructs a StringIO holding the image + img = Image.open(im) + local_large = '%s/%s.jpg' %(large_dir, photo.flickr_id) + if img.format == 'JPEG': + img.save(local_large) + #save medium size + med_dir = settings.IMAGES_ROOT + '/flickr/med/'+ photo.pub_date.strftime("%Y") + if not os.path.isdir(med_dir): + os.makedirs(med_dir) + med = photo.get_medium_url() + fname = urllib.urlopen(med) + im = cStringIO.StringIO(fname.read()) # constructs a StringIO holding the image + img = Image.open(im) + local_med = '%s/%s.jpg' %(med_dir, photo.flickr_id) + img.save(local_med) + +def make_gallery_thumb(photo,set): + crop_dir = settings.IMAGES_ROOT + '/gallery_thumbs/' + if not os.path.isdir(crop_dir): + os.makedirs(crop_dir) + remote = photo.get_original_url() + print(remote) + fname = urllib.urlopen(remote) + im = cStringIO.StringIO(fname.read()) # constructs a StringIO holding the image + img = Image.open(im) + + #calculate crop: + cur_width, cur_height = img.size + new_width, new_height = 291, 350 + ratio = max(float(new_width)/cur_width,float(new_height)/cur_height) + x = (cur_width * ratio) + y = (cur_height * ratio) + xd = abs(new_width - x) + yd = abs(new_height - y) + x_diff = int(xd / 2) + y_diff = int(yd / 2) + box = (int(x_diff), int(y_diff), int(x_diff+new_width), int(y_diff+new_height)) + + #create resized file + resized = img.resize((int(x), int(y)), Image.ANTIALIAS).crop(box) + # save resized file + resized_filename = '%s/%s.jpg' %(crop_dir, set.id) + try: + if img.format == 'JPEG': + resized.save(resized_filename, 'JPEG', quality=95, optimize=True) + else: + resized.save(resized_filename) + except IOError, e: + if os.path.isfile(resized_filename): + os.unlink(resized_filename) + raise e + #os.unlink(img) + + + diff --git a/app/photos/static/image-preview.js b/app/photos/static/image-preview.js new file mode 100644 index 0000000..b8fead5 --- /dev/null +++ b/app/photos/static/image-preview.js @@ -0,0 +1,42 @@ +function build_image_preview () { + var url = window.location.href + var cur = url.split('/')[6]; + if (cur) { + var container = document.createElement("div"); + container.className = "form-row field-image"; + var wrapper = document.createElement("div"); + var label = document.createElement("label"); + label.textContent = "Image:"; + var pwrap = document.createElement("p"); + var img = document.createElement("img"); + + var request = new XMLHttpRequest(); + request.open('GET', '/photos/luximage/data/admin/preview/'+cur+'/', true); + request.onload = function() { + if (request.status >= 200 && request.status < 400) { + var data = JSON.parse(request.responseText); + //console.log(data); + img.src = data['url']; + } else { + console.log("server error"); + } + }; + request.onerror = function() { + console.log("error on request"); + }; + request.send(); + pwrap.appendChild(img); + wrapper.appendChild(label); + wrapper.appendChild(pwrap); + container.appendChild(wrapper); + parent = document.getElementById("luximage_form"); + node = parent.children[1].children[0]; + node.parentNode.insertBefore(container, node.previousSibling); + } else { + return; + } +} + +document.addEventListener("DOMContentLoaded", function(event) { + build_image_preview(); +}); diff --git a/app/photos/static/my_styles.css b/app/photos/static/my_styles.css new file mode 100644 index 0000000..d13c8e4 --- /dev/null +++ b/app/photos/static/my_styles.css @@ -0,0 +1,40 @@ + +/*o.v.*/ + +#id_featured_image { + /*style the "box" in its minimzed state*/ + border:1px solid black; width:230px; overflow:hidden; + height:300px; overflow-y:scroll; + /*animate collapsing the dropdown from open to closed state (v. fast)*/ +} +#id_featured_image input { + /*hide the nasty default radio buttons. like, completely!*/ + position:absolute;top:0;left:0;opacity:0; +} + + +#id_featured_image label { + /*style the labels to look like dropdown options, kinda*/ + color: #000; + display:block; + margin: 2px 2px 2px 10px; + height:102px; + opacity:.6; + background-repeat: no-repeat; +} +#id_featured_image:hover label{ + /*this is how labels render in the "expanded" state. we want to see only the selected radio button in the collapsed menu, and all of them when expanded*/ +} +#id_featured_image label:hover { + opacity:.8; +} +#id_featured_image input:checked + label { + /*tricky! labels immediately following a checked radio button (with our markup they are semantically related) should be fully opaque regardless of hover, and they should always be visible (i.e. even in the collapsed menu*/ + opacity:1 !important; + display:block; + background: #333; +} + +/*pfft, nothing as cool here, just the value trace*/ +#trace {margin:0 0 20px;} +#id_featured_image li:first-child { display: none;} diff --git a/app/photos/templatetags/__init__.py b/app/photos/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/photos/templatetags/get_image_by_size.py b/app/photos/templatetags/get_image_by_size.py new file mode 100644 index 0000000..c56c44e --- /dev/null +++ b/app/photos/templatetags/get_image_by_size.py @@ -0,0 +1,8 @@ +from django import template + +register = template.Library() + +@register.simple_tag +def get_image_by_size(obj, *args): + method = getattr(obj, "get_image_by_size") + return method(*args) diff --git a/app/photos/templatetags/get_image_width.py b/app/photos/templatetags/get_image_width.py new file mode 100644 index 0000000..ac39184 --- /dev/null +++ b/app/photos/templatetags/get_image_width.py @@ -0,0 +1,9 @@ +from math import floor +from django import template + +register = template.Library() + +@register.simple_tag +def get_image_width(obj, size, *args): + ratio = floor(int(size)*100/int(obj.height))/100 + return floor(ratio*int(obj.height)) diff --git a/app/photos/urls.py b/app/photos/urls.py new file mode 100644 index 0000000..73d906c --- /dev/null +++ b/app/photos/urls.py @@ -0,0 +1,56 @@ +from django.urls import path, re_path +from django.views.generic.base import RedirectView + +from . import views + +app_name = "photos" + +urlpatterns = [ + path( + r'daily/', + views.DailyPhotoList.as_view(), + name="daily_photo_list" + ), + path( + r'daily/', + views.DailyPhotoList.as_view(), + {'page': 1}, + name="daily_photo_list" + ), + path( + r'data/(/$', + views.photo_json + ), + path( + r'data/admin/preview//', + views.photo_preview_json, + name="admin_image_preview" + ), + path( + r'data/admin/tn//', + views.thumb_preview_json, + name="admin_thumb_preview" + ), + re_path( + r'galleries/(?P[-\w]+)$', + views.Gallery.as_view(), + name="private" + ), + re_path( + r'galleries/(?P\d+)/$', + views.GalleryList.as_view(), + name="private_list" + ), + path( + r'galleries/', + RedirectView.as_view(url="/photos/galleries/1/", permanent=False) + ), + path( + r'/', + RedirectView.as_view(url="/photos/%(slug)s/1/", permanent=False) + ), + re_path( + r'', + RedirectView.as_view(url="/photos/1/", permanent=False) + ), +] diff --git a/app/photos/utils.py b/app/photos/utils.py new file mode 100644 index 0000000..84e72f5 --- /dev/null +++ b/app/photos/utils.py @@ -0,0 +1,28 @@ +import os +import re +import subprocess + +from django.apps import apps +from django.conf import settings + +from PIL import ImageFile +from bs4 import BeautifulSoup +# pip install python-resize-image +from resizeimage import resizeimage + + +def resize_image(img, width=None, height=None, quality=72, base_path="", filename=""): + if width and height: + newimg = resizeimage.resize_cover(img, [width, height]) + if width and not height: + newimg = resizeimage.resize_width(img, width) + if height and not width: + newimg = resizeimage.resize_height(img, height) + if not os.path.isdir(base_path): + os.makedirs(base_path) + path = "%s%s" % (base_path, filename) + ImageFile.MAXBLOCK = img.size[0] * img.size[1] * 4 + newimg.save(path, newimg.format, quality=quality) + subprocess.call(["jpegoptim", "%s" % path]) + + diff --git a/app/photos/views.py b/app/photos/views.py new file mode 100644 index 0000000..4581e07 --- /dev/null +++ b/app/photos/views.py @@ -0,0 +1,59 @@ +import json +from django.shortcuts import render_to_response, render +from django.template import RequestContext +from django.http import Http404, HttpResponse +from django.core import serializers + +from .models import Photo, PhotoGallery, LuxGallery, LuxImage +from locations.models import Country, Region + +from utils.views import PaginatedListView +from django.views.generic import ListView +from django.views.generic.detail import DetailView + + +class Gallery(DetailView): + model = LuxGallery + slug_field = "slug" + template_name = "details/photo_gallery.html" + + +class GalleryList(PaginatedListView): + template_name = 'archives/gallery_list.html' + + def get_queryset(self): + return LuxGallery.objects.filter(is_public=True) + + def get_context_data(self, **kwargs): + # Call the base implementation first to get a context + context = super(GalleryList, self).get_context_data(**kwargs) + context['is_private'] = False + return context + + +class DailyPhotoList(PaginatedListView): + template_name = 'archives/photo_daily_list.html' + + def get_queryset(self): + return LuxImage.objects.filter(is_public=True, title__startswith="daily_") + + +def photo_json(request, slug): + p = PhotoGallery.objects.filter(set_slug=slug) + return HttpResponse(serializers.serialize('json', p), mimetype='application/json') + + +def photo_preview_json(request, pk): + p = LuxImage.objects.get(pk=pk) + data = {} + data['url'] = p.get_admin_image() + data = json.dumps(data) + return HttpResponse(data) + + +def thumb_preview_json(request, pk): + p = LuxImage.objects.get(pk=pk) + data = {} + data['url'] = p.get_admin_insert() + data = json.dumps(data) + return HttpResponse(data) diff --git a/app/taxonomy/migrations/0001_initial.py b/app/taxonomy/migrations/0001_initial.py new file mode 100644 index 0000000..a181161 --- /dev/null +++ b/app/taxonomy/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 2.1.7 on 2019-03-30 17:07 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=250)), + ('color_rgb', models.CharField(blank=True, max_length=20)), + ('slug', models.SlugField(blank=True)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_updated', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='LuxTag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='Name')), + ('slug', models.SlugField(max_length=100, unique=True, verbose_name='Slug')), + ('color_rgb', models.CharField(blank=True, max_length=20)), + ], + options={ + 'verbose_name_plural': 'Tags', + 'verbose_name': 'Tag', + }, + ), + migrations.CreateModel( + name='TaggedItems', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.IntegerField(db_index=True, verbose_name='Object id')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='taxonomy_taggeditems_tagged_items', to='contenttypes.ContentType', verbose_name='Content type')), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='taxonomy_taggeditems_items', to='taxonomy.LuxTag')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/app/taxonomy/migrations/__init__.py b/app/taxonomy/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/taxonomy/models.py b/app/taxonomy/models.py new file mode 100644 index 0000000..df38ae7 --- /dev/null +++ b/app/taxonomy/models.py @@ -0,0 +1,39 @@ +from django.db import models +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ +from django.utils.functional import cached_property + +from taggit.models import TagBase, GenericTaggedItemBase + + +class LuxTag(TagBase): + ''' override the default taggit model to add some color ''' + color_rgb = models.CharField(max_length=20, blank=True) + + class Meta: + verbose_name = _("Tag") + verbose_name_plural = _("Tags") + + @cached_property + def get_absolute_url(self): + return reverse("taxonomy:tags", kwargs={"slug": self.slug}) + + +class TaggedItems(GenericTaggedItemBase): + ''' necessary with custom tag model, lets you still use TaggableManager''' + tag = models.ForeignKey(LuxTag, related_name="%(app_label)s_%(class)s_items", on_delete=models.CASCADE) + + +class Category(models.Model): + """ Generic model for Categories """ + name = models.CharField(max_length=250) + color_rgb = models.CharField(max_length=20, blank=True) + slug = models.SlugField(blank=True) + date_created = models.DateTimeField(blank=True, auto_now_add=True, editable=False) + date_updated = models.DateTimeField(blank=True, auto_now=True, editable=False) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("taxonomy:categories", kwargs={"slug": self.slug}) diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/forms.py b/app/utils/forms.py new file mode 100644 index 0000000..c1216a1 --- /dev/null +++ b/app/utils/forms.py @@ -0,0 +1,7 @@ +from django import forms + + +class BaseFormWithUser(forms.ModelForm): + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user", None) + super(BaseFormWithUser, self).__init__(*args, **kwargs) diff --git a/app/utils/next_prev.py b/app/utils/next_prev.py new file mode 100644 index 0000000..766add1 --- /dev/null +++ b/app/utils/next_prev.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# from https://github.com/gregplaysguitar/django-next-prev/blob/master/next_prev.py + +from functools import partial + +from django.db import models + +if not locals().get('reduce'): + from functools import reduce + +__version__ = '1.0.1' +VERSION = tuple(map(int, __version__.split('.'))) + + +def get_model_attr(instance, attr): + """Example usage: get_model_attr(instance, 'category__slug')""" + for field in attr.split('__'): + instance = getattr(instance, field) + return instance + + +def next_or_prev_in_order(instance, qs=None, prev=False, loop=False): + """Get the next (or previous with prev=True) item for instance, from the + given queryset (which is assumed to contain instance) respecting + queryset ordering. If loop is True, return the first/last item when the + end/start is reached. """ + + if not qs: + qs = instance.__class__.objects.all() + + if prev: + qs = qs.reverse() + lookup = 'lt' + else: + lookup = 'gt' + + q_list = [] + prev_fields = [] + + if qs.query.extra_order_by: + ordering = qs.query.extra_order_by + elif qs.query.order_by: + ordering = qs.query.order_by + elif qs.query.get_meta().ordering: + ordering = qs.query.get_meta().ordering + else: + ordering = [] + + ordering = list(ordering) + + # if the ordering doesn't contain pk, append it and reorder the queryset + # to ensure consistency + if 'pk' not in ordering and '-pk' not in ordering: + ordering.append('pk') + qs = qs.order_by(*ordering) + + for field in ordering: + if field[0] == '-': + this_lookup = (lookup == 'gt' and 'lt' or 'gt') + field = field[1:] + else: + this_lookup = lookup + q_kwargs = dict([(f, get_model_attr(instance, f)) + for f in prev_fields]) + key = "%s__%s" % (field, this_lookup) + q_kwargs[key] = get_model_attr(instance, field) + q_list.append(models.Q(**q_kwargs)) + prev_fields.append(field) + try: + return qs.filter(reduce(models.Q.__or__, q_list))[0] + except IndexError: + length = qs.count() + if loop and length > 1: + # queryset is reversed above if prev + return qs[0] + return None + + +next_in_order = partial(next_or_prev_in_order, prev=False) +prev_in_order = partial(next_or_prev_in_order, prev=True) diff --git a/app/utils/static/autocomplete.js b/app/utils/static/autocomplete.js new file mode 100644 index 0000000..ad0c70d --- /dev/null +++ b/app/utils/static/autocomplete.js @@ -0,0 +1,10 @@ +function autoCompleteItems() { +var item = document.getElementById('id_ap'); +var singlePresetOpts = new Choices(item, { + searchPlaceholderValue: 'Search for Animal', + placeholder: true, +}); +} +document.addEventListener("DOMContentLoaded", function(event) { + autoCompleteItems(); +}); diff --git a/app/utils/static/choices.css b/app/utils/static/choices.css new file mode 100644 index 0000000..2c1e7f1 --- /dev/null +++ b/app/utils/static/choices.css @@ -0,0 +1,2 @@ +.choices{position:relative;margin-bottom:24px;font-size:16px; min-width:400px;} +.choices:focus{outline:none}.choices:last-child{margin-bottom:0}.choices.is-disabled .choices__inner,.choices.is-disabled .choices__input{background-color:#eaeaea;cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.choices.is-disabled .choices__item{cursor:not-allowed}.choices[data-type*=select-one]{cursor:pointer}.choices[data-type*=select-one] .choices__inner{padding-bottom:7.5px}.choices[data-type*=select-one] .choices__input{display:block;width:100%;padding:10px;border-bottom:1px solid #ddd;background-color:#fff;margin:0}.choices[data-type*=select-one] .choices__button{background-image:url(../../icons/cross-inverse.svg);padding:0;background-size:8px;position:absolute;top:50%;right:0;margin-top:-10px;margin-right:25px;height:20px;width:20px;border-radius:10em;opacity:.5}.choices[data-type*=select-one] .choices__button:focus,.choices[data-type*=select-one] .choices__button:hover{opacity:1}.choices[data-type*=select-one] .choices__button:focus{box-shadow:0 0 0 2px #00bcd4}.choices[data-type*=select-one]:after{content:"";height:0;width:0;border-style:solid;border-color:#333 transparent transparent transparent;border-width:5px;position:absolute;right:11.5px;top:50%;margin-top:-2.5px;pointer-events:none}.choices[data-type*=select-one].is-open:after{border-color:transparent transparent #333 transparent;margin-top:-7.5px}.choices[data-type*=select-one][dir=rtl]:after{left:11.5px;right:auto}.choices[data-type*=select-one][dir=rtl] .choices__button{right:auto;left:0;margin-left:25px;margin-right:0}.choices[data-type*=select-multiple] .choices__inner,.choices[data-type*=text] .choices__inner{cursor:text}.choices[data-type*=select-multiple] .choices__button,.choices[data-type*=text] .choices__button{position:relative;display:inline-block;margin:0 -4px 0 8px;padding-left:16px;border-left:1px solid #008fa1;background-image:url(../../icons/cross.svg);background-size:8px;width:8px;line-height:1;opacity:.75}.choices[data-type*=select-multiple] .choices__button:focus,.choices[data-type*=select-multiple] .choices__button:hover,.choices[data-type*=text] .choices__button:focus,.choices[data-type*=text] .choices__button:hover{opacity:1}.choices__inner{display:inline-block;vertical-align:top;width:100%;background-color:#f9f9f9;padding:7.5px 7.5px 3.75px;border:1px solid #ddd;border-radius:2.5px;font-size:14px;min-height:44px;overflow:hidden}.is-focused .choices__inner,.is-open .choices__inner{border-color:#b7b7b7}.is-open .choices__inner{border-radius:2.5px 2.5px 0 0}.is-flipped.is-open .choices__inner{border-radius:0 0 2.5px 2.5px}.choices__list{margin:0;padding-left:0;list-style:none}.choices__list--single{display:inline-block;padding:4px 16px 4px 4px;width:100%}[dir=rtl] .choices__list--single{padding-right:4px;padding-left:16px}.choices__list--single .choices__item{width:100%}.choices__list--multiple{display:inline}.choices__list--multiple .choices__item{display:inline-block;vertical-align:middle;border-radius:20px;padding:4px 10px;font-size:12px;font-weight:500;margin-right:3.75px;margin-bottom:3.75px;background-color:#00bcd4;border:1px solid #00a5bb;color:#fff;word-break:break-all}.choices__list--multiple .choices__item[data-deletable]{padding-right:5px}[dir=rtl] .choices__list--multiple .choices__item{margin-right:0;margin-left:3.75px}.choices__list--multiple .choices__item.is-highlighted{background-color:#00a5bb;border:1px solid #008fa1}.is-disabled .choices__list--multiple .choices__item{background-color:#aaa;border:1px solid #919191}.choices__list--dropdown{display:none;z-index:1;position:absolute;width:100%;background-color:#fff;border:1px solid #ddd;top:100%;margin-top:-1px;border-bottom-left-radius:2.5px;border-bottom-right-radius:2.5px;overflow:hidden;word-break:break-all}.choices__list--dropdown.is-active{display:block}.is-open .choices__list--dropdown{border-color:#b7b7b7}.is-flipped .choices__list--dropdown{top:auto;bottom:100%;margin-top:0;margin-bottom:-1px;border-radius:.25rem .25rem 0 0}.choices__list--dropdown .choices__list{position:relative;max-height:300px;overflow:auto;-webkit-overflow-scrolling:touch;will-change:scroll-position}.choices__list--dropdown .choices__item{position:relative;padding:10px;font-size:14px}[dir=rtl] .choices__list--dropdown .choices__item{text-align:right}@media (min-width:640px){.choices__list--dropdown .choices__item--selectable{padding-right:100px}.choices__list--dropdown .choices__item--selectable:after{content:attr(data-select-text);font-size:12px;opacity:0;position:absolute;right:10px;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%)}[dir=rtl] .choices__list--dropdown .choices__item--selectable{text-align:right;padding-left:100px;padding-right:10px}[dir=rtl] .choices__list--dropdown .choices__item--selectable:after{right:auto;left:10px}}.choices__list--dropdown .choices__item--selectable.is-highlighted{background-color:#f2f2f2}.choices__list--dropdown .choices__item--selectable.is-highlighted:after{opacity:.5}.choices__item{cursor:default}.choices__item--selectable{cursor:pointer}.choices__item--disabled{cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;opacity:.5}.choices__heading{font-weight:600;font-size:12px;padding:10px;border-bottom:1px solid #f7f7f7;color:gray}.choices__button{text-indent:-9999px;-webkit-appearance:none;-moz-appearance:none;appearance:none;border:0;background-color:transparent;background-repeat:no-repeat;background-position:center;cursor:pointer}.choices__button:focus{outline:none}.choices__input{display:inline-block;vertical-align:baseline;background-color:#f9f9f9;font-size:14px;margin-bottom:5px;border:0;border-radius:0;max-width:100%;padding:4px 0 4px 2px}.choices__input:focus{outline:0}[dir=rtl] .choices__input{padding-right:2px;padding-left:0}.choices__placeholder{opacity:.5} diff --git a/app/utils/static/choices.min.js b/app/utils/static/choices.min.js new file mode 100644 index 0000000..197f18e --- /dev/null +++ b/app/utils/static/choices.min.js @@ -0,0 +1,5 @@ +/*! choices.js v3.0.3 | (c) 2018 Josh Johnson | https://github.com/jshjohnson/Choices#readme */ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Choices=t():e.Choices=t()}(this,function(){return function(e){function t(n){if(i[n])return i[n].exports;var s=i[n]={exports:{},id:n,loaded:!1};return e[n].call(s.exports,s,s.exports,t),s.loaded=!0,s.exports}var i={};return t.m=e,t.c=i,t.p="/assets/scripts/dist/",t(0)}([function(e,t,i){e.exports=i(1)},function(e,t,i){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}function s(e,t,i){return t in e?Object.defineProperty(e,t,{value:i,enumerable:!0,configurable:!0,writable:!0}):e[t]=i,e}function o(e){if(Array.isArray(e)){for(var t=0,i=Array(e.length);t0&&void 0!==arguments[0]?arguments[0]:"[data-choice]",i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(r(this,e),(0,v.isType)("String",t)){var n=document.querySelectorAll(t);if(n.length>1)for(var s=1;s"'+e+'"'},maxItemText:function(e){return"Only "+e+" values can be added."},uniqueItemText:"Only unique values can be added.",classNames:{containerOuter:"choices",containerInner:"choices__inner",input:"choices__input",inputCloned:"choices__input--cloned",list:"choices__list",listItems:"choices__list--multiple",listSingle:"choices__list--single",listDropdown:"choices__list--dropdown",item:"choices__item",itemSelectable:"choices__item--selectable",itemDisabled:"choices__item--disabled",itemChoice:"choices__item--choice",placeholder:"choices__placeholder",group:"choices__group",groupHeading:"choices__heading",button:"choices__button",activeState:"is-active",focusState:"is-focused",openState:"is-open",disabledState:"is-disabled",highlightedState:"is-highlighted",hiddenState:"is-hidden",flippedState:"is-flipped",loadingState:"is-loading",noResults:"has-no-results",noChoices:"has-no-choices"},fuseOptions:{include:"score"},callbackOnInit:null,callbackOnCreateTemplates:null};if(this.idNames={itemChoice:"item-choice"},this.config=(0,v.extend)(a,i),"auto"!==this.config.renderSelectedChoices&&"always"!==this.config.renderSelectedChoices&&(this.config.silent||console.warn("renderSelectedChoices: Possible values are 'auto' and 'always'. Falling back to 'auto'."),this.config.renderSelectedChoices="auto"),this.store=new f.default(this.render),this.initialised=!1,this.currentState={},this.prevState={},this.currentValue="",this.element=t,this.passedElement=(0,v.isType)("String",t)?document.querySelector(t):t,!this.passedElement)return void(this.config.silent||console.error("Passed element not found"));this.isTextElement="text"===this.passedElement.type,this.isSelectOneElement="select-one"===this.passedElement.type,this.isSelectMultipleElement="select-multiple"===this.passedElement.type,this.isSelectElement=this.isSelectOneElement||this.isSelectMultipleElement,this.isValidElementType=this.isTextElement||this.isSelectElement,this.isIe11=!(!navigator.userAgent.match(/Trident/)||!navigator.userAgent.match(/rv[ :]11/)),this.isScrollingOnIe=!1,this.config.shouldSortItems===!0&&this.isSelectOneElement&&(this.config.silent||console.warn("shouldSortElements: Type of passed element is 'select-one', falling back to false.")),this.highlightPosition=0,this.canSearch=this.config.searchEnabled,this.placeholder=!1,this.isSelectOneElement||(this.placeholder=!!this.config.placeholder&&(this.config.placeholderValue||this.passedElement.getAttribute("placeholder"))),this.presetChoices=this.config.choices,this.presetItems=this.config.items,this.passedElement.value&&(this.presetItems=this.presetItems.concat(this.passedElement.value.split(this.config.delimiter))),this.baseId=(0,v.generateId)(this.passedElement,"choices-"),this.render=this.render.bind(this),this._onFocus=this._onFocus.bind(this),this._onBlur=this._onBlur.bind(this),this._onKeyUp=this._onKeyUp.bind(this),this._onKeyDown=this._onKeyDown.bind(this),this._onClick=this._onClick.bind(this),this._onTouchMove=this._onTouchMove.bind(this),this._onTouchEnd=this._onTouchEnd.bind(this),this._onMouseDown=this._onMouseDown.bind(this),this._onMouseOver=this._onMouseOver.bind(this),this._onPaste=this._onPaste.bind(this),this._onInput=this._onInput.bind(this),this.wasTap=!0;var c="classList"in document.documentElement;c||this.config.silent||console.error("Choices: Your browser doesn't support Choices");var l=(0,v.isElement)(this.passedElement)&&this.isValidElementType;if(l){if("active"===this.passedElement.getAttribute("data-choice"))return;this.init()}else this.config.silent||console.error("Incompatible input passed")}return a(e,[{key:"init",value:function(){if(this.initialised!==!0){var e=this.config.callbackOnInit;this.initialised=!0,this._createTemplates(),this._createInput(),this.store.subscribe(this.render),this.render(),this._addEventListeners(),e&&(0,v.isType)("Function",e)&&e.call(this)}}},{key:"destroy",value:function(){if(this.initialised!==!1){this._removeEventListeners(),this.passedElement.classList.remove(this.config.classNames.input,this.config.classNames.hiddenState),this.passedElement.removeAttribute("tabindex");var e=this.passedElement.getAttribute("data-choice-orig-style");Boolean(e)?(this.passedElement.removeAttribute("data-choice-orig-style"),this.passedElement.setAttribute("style",e)):this.passedElement.removeAttribute("style"),this.passedElement.removeAttribute("aria-hidden"),this.passedElement.removeAttribute("data-choice"),this.passedElement.value=this.passedElement.value,this.containerOuter.parentNode.insertBefore(this.passedElement,this.containerOuter),this.containerOuter.parentNode.removeChild(this.containerOuter),this.clearStore(),this.config.templates=null,this.initialised=!1}}},{key:"renderGroups",value:function(e,t,i){var n=this,s=i||document.createDocumentFragment(),o=this.config.sortFilter;return this.config.shouldSort&&e.sort(o),e.forEach(function(e){var i=t.filter(function(t){return n.isSelectOneElement?t.groupId===e.id:t.groupId===e.id&&!t.selected});if(i.length>=1){var o=n._getTemplate("choiceGroup",e);s.appendChild(o),n.renderChoices(i,s,!0)}}),s}},{key:"renderChoices",value:function(e,t){var i=this,n=arguments.length>2&&void 0!==arguments[2]&&arguments[2],s=t||document.createDocumentFragment(),r=this.config,a=r.renderSelectedChoices,c=r.searchResultLimit,l=r.renderChoiceLimit,h=this.isSearching?v.sortByScore:this.config.sortFilter,u=function(e){var t="auto"!==a||(i.isSelectOneElement||!e.selected);if(t){var n=i._getTemplate("choice",e);s.appendChild(n)}},d=e;"auto"!==a||this.isSelectOneElement||(d=e.filter(function(e){return!e.selected}));var f=d.reduce(function(e,t){return t.placeholder?e.placeholderChoices.push(t):e.normalChoices.push(t),e},{placeholderChoices:[],normalChoices:[]}),p=f.placeholderChoices,m=f.normalChoices;(this.config.shouldSort||this.isSearching)&&m.sort(h);var g=d.length,y=[].concat(o(p),o(m));this.isSearching?g=c:l>0&&!n&&(g=l);for(var b=0;b1&&void 0!==arguments[1]?arguments[1]:null,n=i||document.createDocumentFragment();if(this.config.shouldSortItems&&!this.isSelectOneElement&&e.sort(this.config.sortFilter),this.isTextElement){var s=this.store.getItemsReducedToValues(e),o=s.join(this.config.delimiter);this.passedElement.setAttribute("value",o),this.passedElement.value=o}else{var r=document.createDocumentFragment();e.forEach(function(e){var i=t._getTemplate("option",e);r.appendChild(i)}),this.passedElement.innerHTML="",this.passedElement.appendChild(r)}return e.forEach(function(e){var i=t._getTemplate("item",e);n.appendChild(i)}),n}},{key:"render",value:function(){if(!this.store.isLoading()&&(this.currentState=this.store.getState(),this.currentState!==this.prevState)){if((this.currentState.choices!==this.prevState.choices||this.currentState.groups!==this.prevState.groups||this.currentState.items!==this.prevState.items)&&this.isSelectElement){var e=this.store.getGroupsFilteredByActive(),t=this.store.getChoicesFilteredByActive(),i=document.createDocumentFragment();this.choiceList.innerHTML="",this.config.resetScrollPosition&&(this.choiceList.scrollTop=0),e.length>=1&&this.isSearching!==!0?i=this.renderGroups(e,t,i):t.length>=1&&(i=this.renderChoices(t,i));var n=this.store.getItemsFilteredByActive(),s=this._canAddItem(n,this.input.value);if(i.childNodes&&i.childNodes.length>0)s.response?(this.choiceList.appendChild(i),this._highlightChoice()):this.choiceList.appendChild(this._getTemplate("notice",s.notice));else{var o=void 0,r=void 0;this.isSearching?(r=(0,v.isType)("Function",this.config.noResultsText)?this.config.noResultsText():this.config.noResultsText,o=this._getTemplate("notice",r,"no-results")):(r=(0,v.isType)("Function",this.config.noChoicesText)?this.config.noChoicesText():this.config.noChoicesText,o=this._getTemplate("notice",r,"no-choices")),this.choiceList.appendChild(o)}}if(this.currentState.items!==this.prevState.items){var a=this.store.getItemsFilteredByActive();if(this.itemList.innerHTML="",a&&a){var c=this.renderItems(a);c.childNodes&&this.itemList.appendChild(c)}}this.prevState=this.currentState}}},{key:"highlightItem",value:function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];if(!e)return this;var i=e.id,n=e.groupId,s=n>=0?this.store.getGroupById(n):null;return this.store.dispatch((0,p.highlightItem)(i,!0)),t&&(s&&s.value?(0,v.triggerEvent)(this.passedElement,"highlightItem",{id:i,value:e.value,label:e.label,groupValue:s.value}):(0,v.triggerEvent)(this.passedElement,"highlightItem",{id:i,value:e.value,label:e.label})),this}},{key:"unhighlightItem",value:function(e){if(!e)return this;var t=e.id,i=e.groupId,n=i>=0?this.store.getGroupById(i):null;return this.store.dispatch((0,p.highlightItem)(t,!1)),n&&n.value?(0,v.triggerEvent)(this.passedElement,"unhighlightItem",{id:t,value:e.value,label:e.label,groupValue:n.value}):(0,v.triggerEvent)(this.passedElement,"unhighlightItem",{id:t,value:e.value,label:e.label}),this}},{key:"highlightAll",value:function(){var e=this,t=this.store.getItems();return t.forEach(function(t){e.highlightItem(t)}),this}},{key:"unhighlightAll",value:function(){var e=this,t=this.store.getItems();return t.forEach(function(t){e.unhighlightItem(t)}),this}},{key:"removeItemsByValue",value:function(e){var t=this;if(!e||!(0,v.isType)("String",e))return this;var i=this.store.getItemsFilteredByActive();return i.forEach(function(i){i.value===e&&t._removeItem(i)}),this}},{key:"removeActiveItems",value:function(e){var t=this,i=this.store.getItemsFilteredByActive();return i.forEach(function(i){i.active&&e!==i.id&&t._removeItem(i)}),this}},{key:"removeHighlightedItems",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]&&arguments[0],i=this.store.getItemsFilteredByActive();return i.forEach(function(i){i.highlighted&&i.active&&(e._removeItem(i),t&&e._triggerChange(i.value))}),this}},{key:"showDropdown",value:function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=document.body,i=document.documentElement,n=Math.max(t.scrollHeight,t.offsetHeight,i.clientHeight,i.scrollHeight,i.offsetHeight);this.containerOuter.classList.add(this.config.classNames.openState),this.containerOuter.setAttribute("aria-expanded","true"),this.dropdown.classList.add(this.config.classNames.activeState),this.dropdown.setAttribute("aria-expanded","true");var s=this.dropdown.getBoundingClientRect(),o=Math.ceil(s.top+window.scrollY+this.dropdown.offsetHeight),r=!1;return"auto"===this.config.position?r=o>=n:"top"===this.config.position&&(r=!0),r&&this.containerOuter.classList.add(this.config.classNames.flippedState),e&&this.canSearch&&document.activeElement!==this.input&&this.input.focus(),(0,v.triggerEvent)(this.passedElement,"showDropdown",{}),this}},{key:"hideDropdown",value:function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=this.containerOuter.classList.contains(this.config.classNames.flippedState);return this.containerOuter.classList.remove(this.config.classNames.openState),this.containerOuter.setAttribute("aria-expanded","false"),this.dropdown.classList.remove(this.config.classNames.activeState),this.dropdown.setAttribute("aria-expanded","false"),t&&this.containerOuter.classList.remove(this.config.classNames.flippedState),e&&this.canSearch&&document.activeElement===this.input&&this.input.blur(),(0,v.triggerEvent)(this.passedElement,"hideDropdown",{}),this}},{key:"toggleDropdown",value:function(){var e=this.dropdown.classList.contains(this.config.classNames.activeState);return e?this.hideDropdown():this.showDropdown(!0),this}},{key:"getValue",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]&&arguments[0],i=this.store.getItemsFilteredByActive(),n=[];return i.forEach(function(i){e.isTextElement?n.push(t?i.value:i):i.active&&n.push(t?i.value:i)}),this.isSelectOneElement?n[0]:n}},{key:"setValue",value:function(e){var t=this;if(this.initialised===!0){var i=[].concat(o(e)),n=function(e){var i=(0,v.getType)(e);if("Object"===i){if(!e.value)return;t.isTextElement?t._addItem(e.value,e.label,e.id,void 0,e.customProperties,e.placeholder):t._addChoice(e.value,e.label,!0,!1,-1,e.customProperties,e.placeholder)}else"String"===i&&(t.isTextElement?t._addItem(e):t._addChoice(e,e,!0,!1,-1,null))};i.length>1?i.forEach(function(e){n(e)}):n(i[0])}return this}},{key:"setValueByChoice",value:function(e){var t=this;if(!this.isTextElement){var i=this.store.getChoices(),n=(0,v.isType)("Array",e)?e:[e];n.forEach(function(e){var n=i.find(function(t){return t.value===e});n?n.selected?t.config.silent||console.warn("Attempting to select choice already selected"):t._addItem(n.value,n.label,n.id,n.groupId,n.customProperties,n.placeholder,n.keyCode):t.config.silent||console.warn("Attempting to select choice that does not exist")})}return this}},{key:"setChoices",value:function(e,t,i){var n=this,s=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(this.initialised===!0&&this.isSelectElement){if(!(0,v.isType)("Array",e)||!t)return this;s&&this._clearChoices(),this._setLoading(!0),e&&e.length&&(this.containerOuter.classList.remove(this.config.classNames.loadingState),e.forEach(function(e){e.choices?n._addGroup(e,e.id||null,t,i):n._addChoice(e[t],e[i],e.selected,e.disabled,void 0,e.customProperties,e.placeholder)})),this._setLoading(!1)}return this}},{key:"clearStore",value:function(){return this.store.dispatch((0,p.clearAll)()),this}},{key:"clearInput",value:function(){return this.input.value&&(this.input.value=""),this.isSelectOneElement||this._setInputWidth(),!this.isTextElement&&this.config.searchEnabled&&(this.isSearching=!1,this.store.dispatch((0,p.activateChoices)(!0))),this}},{key:"enable",value:function(){if(this.initialised){this.passedElement.disabled=!1;var e=this.containerOuter.classList.contains(this.config.classNames.disabledState);e&&(this._addEventListeners(),this.passedElement.removeAttribute("disabled"),this.input.removeAttribute("disabled"),this.containerOuter.classList.remove(this.config.classNames.disabledState),this.containerOuter.removeAttribute("aria-disabled"),this.isSelectOneElement&&this.containerOuter.setAttribute("tabindex","0"))}return this}},{key:"disable",value:function(){if(this.initialised){this.passedElement.disabled=!0;var e=!this.containerOuter.classList.contains(this.config.classNames.disabledState);e&&(this._removeEventListeners(),this.passedElement.setAttribute("disabled",""),this.input.setAttribute("disabled",""),this.containerOuter.classList.add(this.config.classNames.disabledState),this.containerOuter.setAttribute("aria-disabled","true"),this.isSelectOneElement&&this.containerOuter.setAttribute("tabindex","-1"))}return this}},{key:"ajax",value:function(e){var t=this;return this.initialised===!0&&this.isSelectElement&&(requestAnimationFrame(function(){t._handleLoadingState(!0)}),e(this._ajaxCallback())),this}},{key:"_triggerChange",value:function(e){e&&(0,v.triggerEvent)(this.passedElement,"change",{value:e})}},{key:"_handleButtonAction",value:function(e,t){if(e&&t&&this.config.removeItems&&this.config.removeItemButton){var i=t.parentNode.getAttribute("data-id"),n=e.find(function(e){return e.id===parseInt(i,10)});this._removeItem(n),this._triggerChange(n.value),this.isSelectOneElement&&this._selectPlaceholderChoice()}}},{key:"_selectPlaceholderChoice",value:function(){var e=this.store.getPlaceholderChoice();e&&(this._addItem(e.value,e.label,e.id,e.groupId,null,e.placeholder),this._triggerChange(e.value))}},{key:"_handleItemAction",value:function(e,t){var i=this,n=arguments.length>2&&void 0!==arguments[2]&&arguments[2];if(e&&t&&this.config.removeItems&&!this.isSelectOneElement){var s=t.getAttribute("data-id");e.forEach(function(e){e.id!==parseInt(s,10)||e.highlighted?n||e.highlighted&&i.unhighlightItem(e):i.highlightItem(e)}),document.activeElement!==this.input&&this.input.focus()}}},{key:"_handleChoiceAction",value:function(e,t){if(e&&t){var i=t.getAttribute("data-id"),n=this.store.getChoiceById(i),s=e[0]&&e[0].keyCode?e[0].keyCode:null,o=this.dropdown.classList.contains(this.config.classNames.activeState);if(n.keyCode=s,(0,v.triggerEvent)(this.passedElement,"choice",{choice:n}),n&&!n.selected&&!n.disabled){var r=this._canAddItem(e,n.value);r.response&&(this._addItem(n.value,n.label,n.id,n.groupId,n.customProperties,n.placeholder,n.keyCode),this._triggerChange(n.value))}this.clearInput(),o&&this.isSelectOneElement&&(this.hideDropdown(),this.containerOuter.focus())}}},{key:"_handleBackspace",value:function(e){if(this.config.removeItems&&e){var t=e[e.length-1],i=e.some(function(e){return e.highlighted});this.config.editItems&&!i&&t?(this.input.value=t.value,this._setInputWidth(),this._removeItem(t),this._triggerChange(t.value)):(i||this.highlightItem(t,!1),this.removeHighlightedItems(!0))}}},{key:"_canAddItem",value:function(e,t){var i=!0,n=(0,v.isType)("Function",this.config.addItemText)?this.config.addItemText(t):this.config.addItemText;(this.isSelectMultipleElement||this.isTextElement)&&this.config.maxItemCount>0&&this.config.maxItemCount<=e.length&&(i=!1,n=(0,v.isType)("Function",this.config.maxItemText)?this.config.maxItemText(this.config.maxItemCount):this.config.maxItemText),this.isTextElement&&this.config.addItems&&i&&this.config.regexFilter&&(i=this._regexFilter(t));var s=!e.some(function(e){return(0,v.isType)("String",t)?e.value===t.trim():e.value===t});return s||this.config.duplicateItems||this.isSelectOneElement||!i||(i=!1,n=(0,v.isType)("Function",this.config.uniqueItemText)?this.config.uniqueItemText(t):this.config.uniqueItemText),{response:i,notice:n}}},{key:"_handleLoadingState",value:function(){var e=!(arguments.length>0&&void 0!==arguments[0])||arguments[0],t=this.itemList.querySelector("."+this.config.classNames.placeholder);e?(this.containerOuter.classList.add(this.config.classNames.loadingState),this.containerOuter.setAttribute("aria-busy","true"),this.isSelectOneElement?t?t.innerHTML=this.config.loadingText:(t=this._getTemplate("placeholder",this.config.loadingText),this.itemList.appendChild(t)):this.input.placeholder=this.config.loadingText):(this.containerOuter.classList.remove(this.config.classNames.loadingState),this.isSelectOneElement?t.innerHTML=this.placeholder||"":this.input.placeholder=this.placeholder||"")}},{key:"_ajaxCallback",value:function(){var e=this;return function(t,i,n){if(t&&i){var s=(0,v.isType)("Object",t)?[t]:t;s&&(0,v.isType)("Array",s)&&s.length?(e._handleLoadingState(!1),e._setLoading(!0),s.forEach(function(t){if(t.choices){var s=t.id||null;e._addGroup(t,s,i,n)}else e._addChoice(t[i],t[n],t.selected,t.disabled,void 0,t.customProperties,t.placeholder)}),e._setLoading(!1),e.isSelectOneElement&&e._selectPlaceholderChoice()):e._handleLoadingState(!1),e.containerOuter.removeAttribute("aria-busy")}}}},{key:"_searchChoices",value:function(e){var t=(0,v.isType)("String",e)?e.trim():e,i=(0,v.isType)("String",this.currentValue)?this.currentValue.trim():this.currentValue;if(t.length>=1&&t!==i+" "){var n=this.store.getSearchableChoices(),s=t,o=(0,v.isType)("Array",this.config.searchFields)?this.config.searchFields:[this.config.searchFields],r=Object.assign(this.config.fuseOptions,{keys:o}),a=new l.default(n,r),c=a.search(s);return this.currentValue=t,this.highlightPosition=0,this.isSearching=!0,this.store.dispatch((0,p.filterChoices)(c)),c.length}return 0}},{key:"_handleSearch",value:function(e){if(e){var t=this.store.getChoices(),i=t.some(function(e){return!e.active});if(this.input===document.activeElement)if(e&&e.length>=this.config.searchFloor){var n=0;this.config.searchChoices&&(n=this._searchChoices(e)),(0,v.triggerEvent)(this.passedElement,"search",{value:e,resultCount:n})}else i&&(this.isSearching=!1,this.store.dispatch((0,p.activateChoices)(!0)))}}},{key:"_addEventListeners",value:function(){document.addEventListener("keyup",this._onKeyUp),document.addEventListener("keydown",this._onKeyDown),document.addEventListener("click",this._onClick),document.addEventListener("touchmove",this._onTouchMove),document.addEventListener("touchend",this._onTouchEnd),document.addEventListener("mousedown",this._onMouseDown),document.addEventListener("mouseover",this._onMouseOver),this.isSelectOneElement&&(this.containerOuter.addEventListener("focus",this._onFocus),this.containerOuter.addEventListener("blur",this._onBlur)),this.input.addEventListener("input",this._onInput),this.input.addEventListener("paste",this._onPaste),this.input.addEventListener("focus",this._onFocus),this.input.addEventListener("blur",this._onBlur)}},{key:"_removeEventListeners",value:function(){document.removeEventListener("keyup",this._onKeyUp),document.removeEventListener("keydown",this._onKeyDown),document.removeEventListener("click",this._onClick),document.removeEventListener("touchmove",this._onTouchMove),document.removeEventListener("touchend",this._onTouchEnd),document.removeEventListener("mousedown",this._onMouseDown),document.removeEventListener("mouseover",this._onMouseOver),this.isSelectOneElement&&(this.containerOuter.removeEventListener("focus",this._onFocus),this.containerOuter.removeEventListener("blur",this._onBlur)),this.input.removeEventListener("input",this._onInput),this.input.removeEventListener("paste",this._onPaste),this.input.removeEventListener("focus",this._onFocus),this.input.removeEventListener("blur",this._onBlur)}},{key:"_setInputWidth",value:function(){this.placeholder?this.input.value&&this.input.value.length>=this.placeholder.length/1.25&&(this.input.style.width=(0,v.getWidthOfInput)(this.input)):this.input.style.width=(0,v.getWidthOfInput)(this.input)}},{key:"_onKeyDown",value:function(e){var t,i=this;if(e.target===this.input||this.containerOuter.contains(e.target)){var n=e.target,o=this.store.getItemsFilteredByActive(),r=this.input===document.activeElement,a=this.dropdown.classList.contains(this.config.classNames.activeState),c=this.itemList&&this.itemList.children,l=String.fromCharCode(e.keyCode),h=46,u=8,d=13,f=65,p=27,m=38,g=40,y=33,b=34,E=e.ctrlKey||e.metaKey;this.isTextElement||!/[a-zA-Z0-9-_ ]/.test(l)||a||this.showDropdown(!0),this.canSearch=this.config.searchEnabled;var _=function(){E&&c&&(i.canSearch=!1,i.config.removeItems&&!i.input.value&&i.input===document.activeElement&&i.highlightAll())},S=function(){if(i.isTextElement&&n.value){var t=i.input.value,s=i._canAddItem(o,t);s.response&&(a&&i.hideDropdown(),i._addItem(t),i._triggerChange(t),i.clearInput())}if(n.hasAttribute("data-button")&&(i._handleButtonAction(o,n),e.preventDefault()),a){e.preventDefault();var r=i.dropdown.querySelector("."+i.config.classNames.highlightedState);r&&(o[0]&&(o[0].keyCode=d),i._handleChoiceAction(o,r))}else i.isSelectOneElement&&(a||(i.showDropdown(!0),e.preventDefault()))},I=function(){a&&(i.toggleDropdown(),i.containerOuter.focus())},w=function(){if(a||i.isSelectOneElement){a||i.showDropdown(!0),i.canSearch=!1;var t=e.keyCode===g||e.keyCode===b?1:-1,n=e.metaKey||e.keyCode===b||e.keyCode===y,s=void 0;if(n)s=t>0?Array.from(i.dropdown.querySelectorAll("[data-choice-selectable]")).pop():i.dropdown.querySelector("[data-choice-selectable]");else{var o=i.dropdown.querySelector("."+i.config.classNames.highlightedState);s=o?(0,v.getAdjacentEl)(o,"[data-choice-selectable]",t):i.dropdown.querySelector("[data-choice-selectable]")}s&&((0,v.isScrolledIntoView)(s,i.choiceList,t)||i._scrollToChoice(s,t),i._highlightChoice(s)),e.preventDefault()}},T=function(){!r||e.target.value||i.isSelectOneElement||(i._handleBackspace(o),e.preventDefault())},C=(t={},s(t,f,_),s(t,d,S),s(t,p,I),s(t,m,w),s(t,y,w),s(t,g,w),s(t,b,w),s(t,u,T),s(t,h,T),t);C[e.keyCode]&&C[e.keyCode]()}}},{key:"_onKeyUp",value:function(e){if(e.target===this.input){var t=this.input.value,i=this.store.getItemsFilteredByActive(),n=this._canAddItem(i,t);if(this.isTextElement){var s=this.dropdown.classList.contains(this.config.classNames.activeState);if(t){if(n.notice){var o=this._getTemplate("notice",n.notice);this.dropdown.innerHTML=o.outerHTML}n.response===!0?s||this.showDropdown():!n.notice&&s&&this.hideDropdown()}else s&&this.hideDropdown()}else{var r=46,a=8;e.keyCode!==r&&e.keyCode!==a||e.target.value?this.canSearch&&n.response&&this._handleSearch(this.input.value):!this.isTextElement&&this.isSearching&&(this.isSearching=!1,this.store.dispatch((0,p.activateChoices)(!0)))}this.canSearch=this.config.searchEnabled}}},{key:"_onInput",value:function(){this.isSelectOneElement||this._setInputWidth()}},{key:"_onTouchMove",value:function(){this.wasTap===!0&&(this.wasTap=!1)}},{key:"_onTouchEnd",value:function(e){var t=e.target||e.touches[0].target,i=this.dropdown.classList.contains(this.config.classNames.activeState);this.wasTap===!0&&this.containerOuter.contains(t)&&(t!==this.containerOuter&&t!==this.containerInner||this.isSelectOneElement||(this.isTextElement?document.activeElement!==this.input&&this.input.focus():i||this.showDropdown(!0)),e.stopPropagation()),this.wasTap=!0}},{key:"_onMouseDown",value:function(e){var t=e.target;if(t===this.choiceList&&this.isIe11&&(this.isScrollingOnIe=!0),this.containerOuter.contains(t)&&t!==this.input){var i=void 0,n=this.store.getItemsFilteredByActive(),s=e.shiftKey;(i=(0,v.findAncestorByAttrName)(t,"data-button"))?this._handleButtonAction(n,i):(i=(0,v.findAncestorByAttrName)(t,"data-item"))?this._handleItemAction(n,i,s):(i=(0,v.findAncestorByAttrName)(t,"data-choice"))&&this._handleChoiceAction(n,i),e.preventDefault()}}},{key:"_onClick",value:function(e){var t=e.target,i=this.dropdown.classList.contains(this.config.classNames.activeState),n=this.store.getItemsFilteredByActive();if(this.containerOuter.contains(t))t.hasAttribute("data-button")&&this._handleButtonAction(n,t),i?this.isSelectOneElement&&t!==this.input&&!this.dropdown.contains(t)&&this.hideDropdown(!0):this.isTextElement?document.activeElement!==this.input&&this.input.focus():this.canSearch?this.showDropdown(!0):(this.showDropdown(),this.containerOuter.focus());else{var s=n.some(function(e){return e.highlighted});s&&this.unhighlightAll(),this.containerOuter.classList.remove(this.config.classNames.focusState),i&&this.hideDropdown()}}},{key:"_onMouseOver",value:function(e){(e.target===this.dropdown||this.dropdown.contains(e.target))&&e.target.hasAttribute("data-choice")&&this._highlightChoice(e.target)}},{key:"_onPaste",value:function(e){e.target!==this.input||this.config.paste||e.preventDefault()}},{key:"_onFocus",value:function(e){var t=this,i=e.target;if(this.containerOuter.contains(i)){var n=this.dropdown.classList.contains(this.config.classNames.activeState),s={text:function(){i===t.input&&t.containerOuter.classList.add(t.config.classNames.focusState)},"select-one":function(){t.containerOuter.classList.add(t.config.classNames.focusState),i===t.input&&(n||t.showDropdown())},"select-multiple":function(){i===t.input&&(t.containerOuter.classList.add(t.config.classNames.focusState),n||t.showDropdown(!0))}};s[this.passedElement.type]()}}},{key:"_onBlur",value:function(e){var t=this,i=e.target;if(this.containerOuter.contains(i)&&!this.isScrollingOnIe){var n=this.store.getItemsFilteredByActive(),s=this.dropdown.classList.contains(this.config.classNames.activeState),o=n.some(function(e){return e.highlighted}),r={text:function(){i===t.input&&(t.containerOuter.classList.remove(t.config.classNames.focusState),o&&t.unhighlightAll(),s&&t.hideDropdown())},"select-one":function(){t.containerOuter.classList.remove(t.config.classNames.focusState),i===t.containerOuter&&s&&!t.canSearch&&t.hideDropdown(),i===t.input&&s&&t.hideDropdown()},"select-multiple":function(){i===t.input&&(t.containerOuter.classList.remove(t.config.classNames.focusState),s&&t.hideDropdown(),o&&t.unhighlightAll())}};r[this.passedElement.type]()}else this.isScrollingOnIe=!1,this.input.focus()}},{key:"_regexFilter",value:function(e){if(!e)return!1;var t=this.config.regexFilter,i=new RegExp(t.source,"i");return i.test(e)}},{key:"_scrollToChoice",value:function(e,t){var i=this;if(e){var n=this.choiceList.offsetHeight,s=e.offsetHeight,o=e.offsetTop+s,r=this.choiceList.scrollTop+n,a=t>0?this.choiceList.scrollTop+o-r:e.offsetTop,c=function e(){var n=4,s=i.choiceList.scrollTop,o=!1,r=void 0,c=void 0;t>0?(r=(a-s)/n,c=r>1?r:1,i.choiceList.scrollTop=s+c,s1?r:1,i.choiceList.scrollTop=s-c,s>a&&(o=!0)),o&&requestAnimationFrame(function(i){e(i,a,t)})};requestAnimationFrame(function(e){c(e,a,t)})}}},{key:"_highlightChoice",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,i=Array.from(this.dropdown.querySelectorAll("[data-choice-selectable]")),n=t;if(i&&i.length){var s=Array.from(this.dropdown.querySelectorAll("."+this.config.classNames.highlightedState));s.forEach(function(t){t.classList.remove(e.config.classNames.highlightedState),t.setAttribute("aria-selected","false")}),n?this.highlightPosition=i.indexOf(n):(n=i.length>this.highlightPosition?i[this.highlightPosition]:i[i.length-1],n||(n=i[0])),n.classList.add(this.config.classNames.highlightedState),n.setAttribute("aria-selected","true"),this.containerOuter.setAttribute("aria-activedescendant",n.id)}}},{key:"_addItem",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:-1,n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:-1,s=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null,o=arguments.length>5&&void 0!==arguments[5]&&arguments[5],r=arguments.length>6&&void 0!==arguments[6]?arguments[6]:null,a=(0,v.isType)("String",e)?e.trim():e,c=r,l=this.store.getItems(),h=t||a,u=parseInt(i,10)||-1,d=n>=0?this.store.getGroupById(n):null,f=l?l.length+1:1;return this.config.prependValue&&(a=this.config.prependValue+a.toString()),this.config.appendValue&&(a+=this.config.appendValue.toString()),this.store.dispatch((0,p.addItem)(a,h,f,u,n,s,o,c)), +this.isSelectOneElement&&this.removeActiveItems(f),d&&d.value?(0,v.triggerEvent)(this.passedElement,"addItem",{id:f,value:a,label:h,groupValue:d.value,keyCode:c}):(0,v.triggerEvent)(this.passedElement,"addItem",{id:f,value:a,label:h,keyCode:c}),this}},{key:"_removeItem",value:function(e){if(!e||!(0,v.isType)("Object",e))return this;var t=e.id,i=e.value,n=e.label,s=e.choiceId,o=e.groupId,r=o>=0?this.store.getGroupById(o):null;return this.store.dispatch((0,p.removeItem)(t,s)),r&&r.value?(0,v.triggerEvent)(this.passedElement,"removeItem",{id:t,value:i,label:n,groupValue:r.value}):(0,v.triggerEvent)(this.passedElement,"removeItem",{id:t,value:i,label:n}),this}},{key:"_addChoice",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,i=arguments.length>2&&void 0!==arguments[2]&&arguments[2],n=arguments.length>3&&void 0!==arguments[3]&&arguments[3],s=arguments.length>4&&void 0!==arguments[4]?arguments[4]:-1,o=arguments.length>5&&void 0!==arguments[5]?arguments[5]:null,r=arguments.length>6&&void 0!==arguments[6]&&arguments[6],a=arguments.length>7&&void 0!==arguments[7]?arguments[7]:null;if("undefined"!=typeof e&&null!==e){var c=this.store.getChoices(),l=t||e,h=c?c.length+1:1,u=this.baseId+"-"+this.idNames.itemChoice+"-"+h;this.store.dispatch((0,p.addChoice)(e,l,h,s,n,u,o,r,a)),i&&this._addItem(e,l,h,void 0,o,r,a)}}},{key:"_clearChoices",value:function(){this.store.dispatch((0,p.clearChoices)())}},{key:"_addGroup",value:function(e,t){var i=this,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"value",s=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"label",o=(0,v.isType)("Object",e)?e.choices:Array.from(e.getElementsByTagName("OPTION")),r=t?t:Math.floor((new Date).valueOf()*Math.random()),a=!!e.disabled&&e.disabled;o?(this.store.dispatch((0,p.addGroup)(e.label,r,!0,a)),o.forEach(function(e){var t=e.disabled||e.parentNode&&e.parentNode.disabled;i._addChoice(e[n],(0,v.isType)("Object",e)?e[s]:e.innerHTML,e.selected,t,r,e.customProperties,e.placeholder)})):this.store.dispatch((0,p.addGroup)(e.label,e.id,!1,e.disabled))}},{key:"_getTemplate",value:function(e){if(!e)return null;for(var t=this.config.templates,i=arguments.length,n=Array(i>1?i-1:0),s=1;s
\n ')},containerInner:function(){return(0,v.strToEl)('\n
\n ')},itemList:function(){var i,n=(0,u.default)(t.list,(i={},s(i,t.listSingle,e.isSelectOneElement),s(i,t.listItems,!e.isSelectOneElement),i));return(0,v.strToEl)('\n
\n ')},placeholder:function(e){return(0,v.strToEl)('\n
\n '+e+"\n
\n ")},item:function(i){var n,o=(0,u.default)(t.item,(n={},s(n,t.highlightedState,i.highlighted),s(n,t.itemSelectable,!i.highlighted),s(n,t.placeholder,i.placeholder),n));if(e.config.removeItemButton){var r;return o=(0,u.default)(t.item,(r={},s(r,t.highlightedState,i.highlighted),s(r,t.itemSelectable,!i.disabled),s(r,t.placeholder,i.placeholder),r)),(0,v.strToEl)('\n \n "+i.label+'\n Remove item\n \n
\n ")}return(0,v.strToEl)('\n \n "+i.label+"\n
\n ")},choiceList:function(){return(0,v.strToEl)('\n \n \n ")},choiceGroup:function(e){var i=(0,u.default)(t.group,s({},t.itemDisabled,e.disabled));return(0,v.strToEl)('\n \n
'+e.value+"
\n \n ")},choice:function(i){var n,o=(0,u.default)(t.item,t.itemChoice,(n={},s(n,t.itemDisabled,i.disabled),s(n,t.itemSelectable,!i.disabled),s(n,t.placeholder,i.placeholder),n));return(0,v.strToEl)('\n 0?'role="treeitem"':'role="option"')+"\n >\n "+i.label+"\n \n ")},input:function(){var e=(0,u.default)(t.input,t.inputCloned);return(0,v.strToEl)('\n \n ')},dropdown:function(){var e=(0,u.default)(t.list,t.listDropdown);return(0,v.strToEl)('\n