diff options
33 files changed, 614 insertions, 248 deletions
diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py index 0e2775e..36aa6ba 100644 --- a/apps/accounts/migrations/0001_initial.py +++ b/apps/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.1.2 on 2018-11-02 19:01 +# Generated by Django 2.1.2 on 2018-11-24 04:41 from django.conf import settings import django.contrib.auth.models diff --git a/apps/accounts/models.py b/apps/accounts/models.py index a930081..5ed7634 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -15,6 +15,8 @@ class UserProfile(models.Model): website = models.CharField(max_length=300, null=True, blank=True, default='') location = models.CharField(max_length=300, null=True, blank=True, default='') bio = models.TextField(null=True, blank=True, default='') + #default_note_public = models.BooleanField(default=False) + #default_note_folder = models.ForeignKey('notes.Folder', null=True, on_delete=models.SET_NULL) def __str__(self): return self.user.username diff --git a/apps/notes/admin.py b/apps/notes/admin.py index dbac05c..3958d55 100644 --- a/apps/notes/admin.py +++ b/apps/notes/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Note, Folder +from .models import Note, Notebook @admin.register(Note) @@ -8,6 +8,6 @@ class NoteAdmin(admin.ModelAdmin): pass -@admin.register(Folder) -class FolderAdmin(admin.ModelAdmin): +@admin.register(Notebook) +class NotebookAdmin(admin.ModelAdmin): pass diff --git a/apps/notes/forms.py b/apps/notes/forms.py index 6a27ec9..a7a4f8d 100644 --- a/apps/notes/forms.py +++ b/apps/notes/forms.py @@ -7,9 +7,9 @@ from .models import Note class NoteForm(forms.ModelForm): class Meta: model = Note - fields = ['title', 'body_markdown', 'url', 'tags'] + fields = ['title', 'body_text', 'body_html', 'body_qjson', 'notebook', 'url', 'tags'] labels = { - "body_markdown": _("Note"), + "body": _("Note"), } def __init__(self, *args, **kwargs): diff --git a/apps/notes/migrations/0001_initial.py b/apps/notes/migrations/0001_initial.py index 3f04eb7..f8e2fff 100644 --- a/apps/notes/migrations/0001_initial.py +++ b/apps/notes/migrations/0001_initial.py @@ -1,9 +1,11 @@ -# Generated by Django 2.1.2 on 2018-11-11 18:01 +# Generated by Django 2.1.2 on 2018-11-24 13:55 from django.conf import settings +import django.contrib.postgres.fields.jsonb from django.db import migrations, models import django.db.models.deletion import taggit.managers +import uuid class Migration(migrations.Migration): @@ -11,28 +13,61 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('taggit', '0002_auto_20150616_2121'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Folder', + name='Note', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=250)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('unique_id', models.UUIDField(default=uuid.uuid4, editable=False)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_updated', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=250)), + ('body_text', models.TextField(null=True)), + ('body_html', models.TextField(blank=True, null=True)), + ('body_qjson', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), + ('url', models.CharField(blank=True, max_length=250, null=True)), + ('slug', models.SlugField(blank=True)), + ('is_public', models.BooleanField(default=False)), ], ), migrations.CreateModel( - name='Note', + name='Notebook', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=250)), - ('body_markdown', models.TextField(null=True)), - ('url', models.CharField(max_length=250)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('tags', taggit.managers.TaggableManager(blank=True, help_text='Tags', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), + ('unique_id', models.UUIDField(default=uuid.uuid4, editable=False)), + ('name', models.CharField(max_length=250)), + ('url', models.CharField(blank=True, max_length=250, null=True)), + ('slug', models.SlugField(blank=True)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_updated', models.DateTimeField(auto_now=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), + migrations.AddField( + model_name='note', + name='notebook', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='notes.Notebook'), + ), + migrations.AddField( + model_name='note', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='note', + name='tags', + field=taggit.managers.TaggableManager(blank=True, help_text='Tags', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AlterUniqueTogether( + name='notebook', + unique_together={('owner', 'slug')}, + ), + migrations.AlterUniqueTogether( + name='note', + unique_together={('owner', 'slug')}, + ), ] diff --git a/apps/notes/migrations/0002_auto_20181111_1825.py b/apps/notes/migrations/0002_auto_20181111_1825.py deleted file mode 100644 index a7bc1c7..0000000 --- a/apps/notes/migrations/0002_auto_20181111_1825.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 2.1.2 on 2018-11-11 18:25 - -import datetime -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('notes', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='note', - name='date_created', - field=models.DateTimeField(blank=True, default=datetime.datetime(2018, 11, 11, 18, 25, 29, 645561), editable=False), - preserve_default=False, - ), - migrations.AddField( - model_name='note', - name='date_updated', - field=models.DateTimeField(blank=True, default=datetime.datetime(2018, 11, 11, 18, 25, 42, 867666), editable=False), - preserve_default=False, - ), - migrations.AddField( - model_name='note', - name='slug', - field=models.SlugField(blank=True, unique=True), - ), - ] diff --git a/apps/notes/migrations/0003_note_folder.py b/apps/notes/migrations/0003_note_folder.py deleted file mode 100644 index d548429..0000000 --- a/apps/notes/migrations/0003_note_folder.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.1.2 on 2018-11-14 15:41 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('notes', '0002_auto_20181111_1825'), - ] - - operations = [ - migrations.AddField( - model_name='note', - name='folder', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='notes.Folder'), - ), - ] diff --git a/apps/notes/migrations/0004_auto_20181117_2039.py b/apps/notes/migrations/0004_auto_20181117_2039.py deleted file mode 100644 index 6fc6f2d..0000000 --- a/apps/notes/migrations/0004_auto_20181117_2039.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 2.1.2 on 2018-11-18 02:39 - -import datetime -from django.db import migrations, models -from django.utils.timezone import utc - - -class Migration(migrations.Migration): - - dependencies = [ - ('notes', '0003_note_folder'), - ] - - operations = [ - migrations.AddField( - model_name='folder', - name='date_created', - field=models.DateTimeField(blank=True, default=datetime.datetime(2018, 11, 18, 2, 38, 45, 996162, tzinfo=utc), editable=False), - preserve_default=False, - ), - migrations.AddField( - model_name='folder', - name='date_updated', - field=models.DateTimeField(blank=True, default=datetime.datetime(2018, 11, 18, 2, 39, 0, 850658, tzinfo=utc), editable=False), - preserve_default=False, - ), - ] diff --git a/apps/notes/migrations/0005_auto_20181119_1145.py b/apps/notes/migrations/0005_auto_20181119_1145.py deleted file mode 100644 index 9b6cee7..0000000 --- a/apps/notes/migrations/0005_auto_20181119_1145.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 2.1.2 on 2018-11-19 17:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('notes', '0004_auto_20181117_2039'), - ] - - operations = [ - migrations.RenameField( - model_name='note', - old_name='body_markdown', - new_name='body', - ), - migrations.AddField( - model_name='note', - name='is_public', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='note', - name='rendered_body', - field=models.TextField(null=True), - ), - ] diff --git a/apps/notes/models.py b/apps/notes/models.py index 318b079..83766a9 100644 --- a/apps/notes/models.py +++ b/apps/notes/models.py @@ -1,48 +1,67 @@ +import uuid from django.db import models from django.utils import timezone +from django.utils.functional import cached_property from django.template.defaultfilters import slugify from django.urls import reverse +from django.contrib.postgres.fields import JSONField from taggit.managers import TaggableManager from django.conf import settings +from utils.util import unique_slug_generator -class Folder(models.Model): - created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + +class Notebook(models.Model): + unique_id = models.UUIDField(default=uuid.uuid4, editable=False) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) name = models.CharField(max_length=250) - date_created = models.DateTimeField(blank=True, editable=False) - date_updated = models.DateTimeField(blank=True, editable=False) + url = models.CharField(max_length=250, null=True, 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) + + class Meta: + unique_together = ("owner", "slug") def __str__(self): return self.name + def save(self, **kwargs): + if self._state.adding: + self.slug = unique_slug_generator(self) + super(Notebook, self).save() + class Note(models.Model): - created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - date_created = models.DateTimeField(blank=True, editable=False) - date_updated = models.DateTimeField(blank=True, editable=False) + unique_id = models.UUIDField(default=uuid.uuid4, editable=False) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + date_created = models.DateTimeField(blank=True, auto_now_add=True, editable=False) + date_updated = models.DateTimeField(blank=True, auto_now=True, editable=False) title = models.CharField(max_length=250) - body = models.TextField(null=True) - rendered_body = models.TextField(null=True) - url = models.CharField(max_length=250) - slug = models.SlugField(unique=True, blank=True) - folder = models.ForeignKey(Folder, null=True, on_delete=models.SET_NULL) + body_text = models.TextField(null=True) + body_html = models.TextField(null=True, blank=True) + body_qjson = JSONField(null=True, blank=True) + url = models.CharField(max_length=250, null=True, blank=True) + slug = models.SlugField(blank=True) + notebook = models.ForeignKey(Notebook, null=True, blank=True, on_delete=models.SET_NULL) tags = TaggableManager(blank=True, help_text='Tags') is_public = models.BooleanField(default=False) + class Meta: + unique_together = ("owner", "slug") + def __str__(self): return self.title + @cached_property def get_absolute_url(self): - return reverse("notes:notes-detail", kwargs={"pk": self.pk}) + return reverse("notes:note-detail", kwargs={"user": self.owner.username, "slug": self.slug}) def save(self, **kwargs): - # On save, update timestamps (users updated through admin.py) - if not self.id: - self.date_created = timezone.now() - self.slug = slugify(self.title)[:50] - if not self.title: - self.title = str(self.date_created) - self.date_updated = timezone.now() + if not self.title: + self.title = str(self.body_text)[:50] + if self._state.adding: + self.slug = unique_slug_generator(self) super(Note, self).save() diff --git a/apps/notes/serializers.py b/apps/notes/serializers.py index 0929e79..2fbcc1f 100644 --- a/apps/notes/serializers.py +++ b/apps/notes/serializers.py @@ -1,18 +1,18 @@ from rest_framework import serializers from taggit_serializer.serializers import TagListSerializerField, TaggitSerializer -from .models import Note, Folder +from .models import Note, Notebook -class NoteSerializer(TaggitSerializer, serializers.HyperlinkedModelSerializer): +class NoteSerializer(TaggitSerializer, serializers.ModelSerializer): tags = TagListSerializerField() class Meta: model = Note - fields = ('title', 'body', 'url', 'tags') + fields = ('title', 'body_text', 'body_qjson', 'body_html', 'url', 'notebook', 'tags') -class FolderSerializer(serializers.HyperlinkedModelSerializer): +class NotebookSerializer(serializers.HyperlinkedModelSerializer): class Meta: - model = Folder + model = Notebook fields = ('name',) diff --git a/apps/notes/tests/test_models.py b/apps/notes/tests/test_models.py index ddb731e..cf21aca 100644 --- a/apps/notes/tests/test_models.py +++ b/apps/notes/tests/test_models.py @@ -1,32 +1,32 @@ from django.test import TestCase from mixer.backend.django import mixer -from notes.models import Note, Folder +from notes.models import Note, Notebook from accounts.models import User -class FolderModelTest(TestCase): +class NotebookModelTest(TestCase): def test_string_representation(self): user = mixer.blend(User, username='tpynchon') - folder = Folder(created_by=user, name="My Folder") - self.assertEqual(str(folder), "My Folder") + notebook = Notebook(owner=user, name="San Miguel Notes") + self.assertEqual(str(notebook), "San Miguel Notes") class NoteModelTest(TestCase): def setUp(self): - self.user = mixer.blend(User, username='test') + self.user = mixer.blend(User, username='tpynchon') self.note = Note.objects.create( - created_by=self.user, + owner=self.user, title="test note", - body="the body of the note", + body_text="the body of the note", url="https://luxagraf.net/", tags="mine,cool site" ) self.note.save() self.note_no_title = Note.objects.create( - created_by=self.user, - body="the body of the note", + owner=self.user, + body_text="the body of the note", url="https://luxagraf.net/", tags="mine,cool site" ) @@ -34,11 +34,11 @@ class NoteModelTest(TestCase): def test_string_representation(self): self.assertEqual(str(self.note), "test note") - self.assertEqual(str(self.note.body), "the body of the note") + self.assertEqual(str(self.note.body_text), "the body of the note") self.assertEqual(str(self.note.url), "https://luxagraf.net/") self.assertEqual(str(self.note.tags), "mine,cool site") # titleless note gets date - self.assertEqual(str(self.note_no_title), str(self.note_no_title.date_created)) + self.assertEqual(str(self.note_no_title), str(self.note_no_title.body_text)[:50]) def test_get_absolute_url(self): - self.assertEqual(str(self.note.get_absolute_url()), "/notes/%s/" % (self.note.id)) + self.assertEqual(str(self.note.get_absolute_url), "/notes/%s/%s" % (self.note.owner.username, self.note.slug)) diff --git a/apps/notes/tests/test_views.py b/apps/notes/tests/test_views.py index 05cb8db..4f9a4ce 100644 --- a/apps/notes/tests/test_views.py +++ b/apps/notes/tests/test_views.py @@ -2,6 +2,7 @@ import json from django.test import Client from django.test import RequestFactory, TestCase from django.urls import reverse +from django.template.defaultfilters import slugify from rest_framework.test import force_authenticate from rest_framework.test import APIRequestFactory @@ -25,9 +26,9 @@ class NotesViewsTest(TestCase): self.user = User.objects.create(username='testuser', password='password') self.bad_user = User.objects.create(username='someoneelse', password='password') self.note = Note.objects.create( - created_by=self.user, + owner=self.user, title="test note", - body="the body of the note", + body_text="the body of the note", url="https://luxagraf.net/", ) self.note.tags.add("mine,cool site") @@ -40,6 +41,19 @@ class NotesViewsTest(TestCase): self.assertEqual(response.status_code, 200) # bad_user + def test_note_create_view(self): + data = { + 'title': "test note post", + 'body_text': "the body of the note", + 'url': "https://luxagraf.net/", + 'tags': [], + } + self.client.force_login(self.user) + url = reverse("notes:note-create") + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, '/notes/%s/%s' % (self.user.username, slugify(data['title']))) + def test_api_list(self): # Make an authenticated request to the view... request = self.factory.get('/api/notes/') @@ -51,36 +65,36 @@ class NotesViewsTest(TestCase): self.assertEqual(api_data['title'], 'test note') self.assertEqual(api_data['tags'], ['mine,cool site']) - def test_note_create(self): + def test_api_note_create(self): ''' - post some data to create a new note + Post some data to create a new note ''' data = { 'title': "test note post", - 'body': "the body of the note", + 'body_text': "the body of the note", 'url': "https://luxagraf.net/", 'tags': [], } self.apiclient.force_authenticate(self.user) - url = reverse("notes:notes-list") + url = reverse("notes-api-list") response = self.apiclient.post(url, data, format='json') self.assertEqual(response.status_code, 201) self.assertEqual(Note.objects.count(), 2) response.render() api_data = json.loads(response.content.decode('utf8')) self.assertEqual(api_data['title'], 'test note post') - self.assertEqual(api_data['body'], 'the body of the note') + self.assertEqual(api_data['body_text'], 'the body of the note') self.assertEqual(api_data['tags'], []) def test_note_create_bad(self): # create another user data = { 'title': "", - 'body': "the body of the note", + 'body_text': "the body of the note", 'url': "https://luxagraf.net/", 'tags': [], } - url = reverse("notes:notes-list") + url = reverse("notes-api-list") self.apiclient.force_authenticate(self.user) response = self.apiclient.post(url, data, format='json') self.assertEqual(response.status_code, 400) diff --git a/apps/notes/urls.py b/apps/notes/urls.py index cbb1884..ccfcc9e 100644 --- a/apps/notes/urls.py +++ b/apps/notes/urls.py @@ -1,17 +1,17 @@ from django.urls import path -from django.conf.urls import include -from rest_framework import routers - -from .views import NoteViewSet, FolderViewSet, NoteListView - -router = routers.DefaultRouter() -router.register(r'notes/folder', FolderViewSet, basename="folder") -router.register(r'notes', NoteViewSet, basename="notes") +from .views import ( + NoteDetailView, + NoteCreateView, + NoteListView, + NoteListRedirectView, +) app_name = "notes" urlpatterns = [ - path(r'', NoteListView.as_view(), name='homepage',), - path(r'', include(router.urls)), + path(r'create/', NoteCreateView.as_view(), name='note-create',), + path(r'<str:user>/<slug>', NoteDetailView.as_view(), name='note-detail',), + path(r'<str:user>/', NoteListView.as_view(), name='note-list',), + path(r'', NoteListRedirectView.as_view(), name='note-redirect',), ] diff --git a/apps/notes/views.py b/apps/notes/views.py index e4b8fda..b971390 100644 --- a/apps/notes/views.py +++ b/apps/notes/views.py @@ -1,21 +1,23 @@ from django.views.generic import CreateView, ListView, UpdateView, DeleteView from django.views.generic.detail import DetailView +from django.views.generic.base import View, RedirectView from django.utils.decorators import method_decorator from django.contrib.auth.decorators import login_required from django.shortcuts import get_object_or_404, render, redirect -from django.urls import reverse +from django.urls import reverse, reverse_lazy from rest_framework import viewsets from rest_framework.response import Response from rest_framework.decorators import list_route from rest_framework import permissions -from .serializers import NoteSerializer, FolderSerializer -from .models import Note, Folder +from .serializers import NoteSerializer, NotebookSerializer +from .models import Note, Notebook +from .forms import NoteForm @method_decorator(login_required, name='dispatch') -class LoggedInCreateViewWithUser(CreateView): +class LoggedInViewWithUser(View): def get_form_kwargs(self, **kwargs): kwargs = super().get_form_kwargs(**kwargs) @@ -23,20 +25,56 @@ class LoggedInCreateViewWithUser(CreateView): return kwargs -class NoteListView(ListView): +class NoteListView(LoggedInViewWithUser, ListView): model = Note def get_queryset(self): if not self.request.user.is_anonymous: - return Note.objects.filter(created_by=self.request.user) + return Note.objects.filter(owner=self.request.user) def get_template_names(self): + # print("IP Address for debug-toolbar: " + self.request.META['REMOTE_ADDR']) if not self.request.user.is_anonymous: return ['notes/notes_list.html'] else: return ['sell.html'] +class NoteListRedirectView(RedirectView, LoggedInViewWithUser): + + def get_redirect_url(self, *args, **kwargs): + return reverse_lazy("notes:note-list", kwargs={"user": self.request.user.username}) + + +class NoteDetailView(UpdateView, LoggedInViewWithUser): + model = Note + form_class = NoteForm + template_name = 'notes/notes_detail.html' + + def get_queryset(self): + if not self.request.user.is_anonymous: + return Note.objects.filter(owner=self.request.user) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['notes_list'] = Note.objects.filter(owner=self.request.user) + return context + + +class NoteCreateView(CreateView, LoggedInViewWithUser): + model = Note + form_class = NoteForm + template_name = 'notes/notes_create.html' + + def form_valid(self, form): + form.instance.owner = self.request.user + self.object = form.save() + return super(NoteCreateView, self).form_valid(form) + + def get_success_url(self): + return reverse_lazy('notes:note-detail', kwargs={'user': self.request.user.username, 'slug': self.object.slug}) + + class IsOwnerOrDeny(permissions.BasePermission): """ Custom permission to only allow owners to post to their endpoint @@ -52,15 +90,13 @@ class NoteViewSet(viewsets.ModelViewSet): API endpoint that allows notes to be viewed or edited. """ serializer_class = NoteSerializer - permission_classes = (permissions.IsAuthenticated, IsOwnerOrDeny,) + permission_classes = (permissions.IsAuthenticated,) def get_queryset(self): - return Note.objects.filter(created_by=self.request.user).order_by('-date_created') + return Note.objects.filter(owner=self.request.user).order_by('-date_created') - @list_route(methods=['post']) def perform_create(self, serializer): - serializer.save(created_by=self.request.user) - return super(NoteViewSet, self).perform_create(serializer) + serializer.save(owner=self.request.user) def get_object(self): obj = get_object_or_404(self.get_queryset(), pk=self.kwargs["pk"]) @@ -71,14 +107,14 @@ class NoteViewSet(viewsets.ModelViewSet): return obj -class FolderViewSet(viewsets.ModelViewSet): +class NotebookViewSet(viewsets.ModelViewSet): """ - API endpoint that allows folder to be viewed or edited. + API endpoint that allows botebook to be viewed or edited. """ - serializer_class = FolderSerializer + serializer_class = NotebookSerializer def get_queryset(self): - return Folder.objects.filter(created_by=self.request.user).order_by('-date_created') + return Notebook.objects.filter(owner=self.request.user).order_by('-date_created') def perform_create(self, serializer): - serializer.save(created_by=self.request.user) + serializer.save(owner=self.request.user) diff --git a/apps/utils/util.py b/apps/utils/util.py index 0c089ee..899b73f 100644 --- a/apps/utils/util.py +++ b/apps/utils/util.py @@ -1,5 +1,8 @@ import re +import random +import string from django.apps import apps +from django.utils.text import slugify from django.template.loader import render_to_string from bs4 import BeautifulSoup import markdown @@ -82,3 +85,23 @@ def parse_video(s): if soup.find('video'): return True return False + + +def random_string_generator(size=10, chars=string.ascii_lowercase + string.digits): + return ''.join(random.choice(chars) for _ in range(size)) + + +def unique_slug_generator(instance, new_slug=None): + if new_slug is not None: + slug = new_slug + else: + slug = slugify(instance.title) + Klass = instance.__class__ + qs_exists = Klass.objects.filter(slug=slug).exists() + if qs_exists: + new_slug = "{slug}-{randstr}".format( + slug=slug, + randstr=random_string_generator(size=4) + ) + return unique_slug_generator(instance, new_slug=new_slug) + return slug diff --git a/config/base_urls.py b/config/base_urls.py index 84dd56d..953ceae 100644 --- a/config/base_urls.py +++ b/config/base_urls.py @@ -9,25 +9,39 @@ from django_registration.backends.activation.views import RegistrationView from rest_framework import routers from pages.views import PageDetailView -from notes.views import NoteViewSet, FolderViewSet, NoteListView from accounts.forms import UserForm - +from notes.views import ( + NoteViewSet, + NotebookViewSet, + NoteListView, +) router = routers.DefaultRouter() -router.register(r'<str:user>/notes/folder/', FolderViewSet, basename="folder-api") -router.register(r'<str:user>/notes/', NoteViewSet, basename="notes-api") - +router.register(r'notes/notebook/', NotebookViewSet, basename="notebook-api") +router.register(r'notes', NoteViewSet, basename="notes-api") +ADMIN_URL = 'https://docs.djangoproject.com/en/dev/ref/contrib/admin/' urlpatterns = static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += [ - path('admin/', admin.site.urls), + path('admin/', RedirectView.as_view(url=ADMIN_URL)), + path('magux/', admin.site.urls), path(r'accounts/', include('django_registration.backends.activation.urls')), path(r'accounts/', include('django.contrib.auth.urls')), path(r'register/', RegistrationView.as_view(form_class=UserForm), name='django_registration_register',), path(r'settings/', include('accounts.urls')), path(r'', include('django_registration.backends.activation.urls')), path(r'', include('django.contrib.auth.urls')), - path(r'', include('notes.urls')), - path(r'<slug>', PageDetailView.as_view(), name="pages"), - path(r'<path>/<slug>/', PageDetailView.as_view(), name="pages"), + path(r'', NoteListView.as_view(), name='homepage',), + path(r'notes/', include('notes.urls')), path(r'api/', include(router.urls)), + path(r'<slug>', PageDetailView.as_view(), name="pages"), + #path(r'<path>/<slug>/', PageDetailView.as_view(), name="pages"), path(r'api-auth/', include('rest_framework.urls', namespace='rest_framework')) ] +if settings.DEBUG: + import debug_toolbar + urlpatterns = [ + path('__debug__/', include(debug_toolbar.urls)), + + # For django versions before 2.0: + # url(r'^__debug__/', include(debug_toolbar.urls)), + + ] + urlpatterns diff --git a/config/requirements.txt b/config/requirements.txt index fb66a23..cfe5b7f 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -7,6 +7,7 @@ confusable-homoglyphs==3.2.0 coverage==4.5.1 decorator==4.3.0 Django==2.1.2 +django-debug-toolbar==1.10.1 django-extensions==2.1.3 django-registration==3.0 django-storages==1.7.1 @@ -37,6 +38,7 @@ python-decouple==3.1 pytz==2018.7 requests==2.20.1 six==1.11.0 +sqlparse==0.2.4 text-unidecode==1.2 traitlets==4.3.2 urllib3==1.24.1 diff --git a/config/settings.py b/config/settings.py index 52d2314..b95c18d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -25,6 +25,7 @@ AUTH_PROFILE_MODULE = "accounts.UserProfile" AUTH_USER_MODEL = "accounts.User" DEFAULT_FILE_STORAGE = config('DEFAULT_FILE_STORAGE') +INTERNAL_IPS = config('INTERNAL_IPS') AWS_S3_OBJECT_PARAMETERS = { 'CacheControl': 'max-age=86400', @@ -39,6 +40,9 @@ REGISTRATION_SALT = 'Astra inclinant, sed non obligant' # Django Rest Framework REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.SessionAuthentication', + ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', ) @@ -60,6 +64,7 @@ THIRD_PARTY_APPS = [ 'taggit_serializer', 'django_extensions', 'rest_framework', + 'debug_toolbar' ] LOCAL_APPS = [ 'utils', @@ -75,6 +80,7 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', + 'debug_toolbar.middleware.DebugToolbarMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', @@ -164,5 +170,23 @@ STATIC_ROOT = BASE_DIR+'/static/' MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR+'/media/' +LOGOUT_REDIRECT_URL = "/" + EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend" EMAIL_FILE_PATH = os.path.join(BASE_DIR, "sent_emails") + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), + }, + }, +} diff --git a/design/sass/_forms.scss b/design/sass/_forms.scss index cac2442..d6b0931 100644 --- a/design/sass/_forms.scss +++ b/design/sass/_forms.scss @@ -86,6 +86,35 @@ table { border: 1px solid $link_hover_color; } } +.btn-small { + @include fontsize(10); + @include smcaps; +} +.btn-subtle { + padding: 3px 5px; + border: none; //1px solid $body_font_light; + color: $body_font_light !important; + background: white; + &:hover { + background: white; + border: none; //1px solid; + color: $link_color !important; + } +} +.btn-accent { + padding: 3px 5px; + border: 1px solid $text_accent; + background: $text_accent; + color: white !important; + outline: $text_accent; + &:hover { + background: $text_accent; + border: 1px solid $text_accent; + } +} +.btn-inline { + display: inline; +} .form-narrow { margin: 0 auto; max-width: 60%; @@ -94,3 +123,8 @@ table { @include fontsize(24); @include fancy-serif; } +.highlight { + border: 1px solid lighten($body_font_light, 20); + border-radius: 4px; + padding: 6px; +} diff --git a/design/sass/_global.scss b/design/sass/_global.scss index e852855..d394042 100644 --- a/design/sass/_global.scss +++ b/design/sass/_global.scss @@ -153,11 +153,11 @@ h3 { } .wrapper { @include constrain_wide; - margin-top: 5rem; + //margin-top: 5rem; } //************** Universals ************************ .hide { - display: none; + display: none !important; } .strike { @@ -183,6 +183,9 @@ h3 { .sm { max-width: 80px; } +.left-margin-2 { + margin-left: 2px; +} //************** other global classes ************************ .sans { @include generic_sans; diff --git a/design/sass/_header.scss b/design/sass/_header.scss index 8b8c3da..7f98419 100644 --- a/design/sass/_header.scss +++ b/design/sass/_header.scss @@ -13,6 +13,7 @@ header { } } .right { + margin-top: 4px; float: right; } } diff --git a/design/sass/_mixins.scss b/design/sass/_mixins.scss index 1aeeb83..bfccc17 100644 --- a/design/sass/_mixins.scss +++ b/design/sass/_mixins.scss @@ -9,6 +9,7 @@ $headline_font_serif: Georgia, 'Times New Roman', serif; $body_p_font: normal 100% / 1.5 "proxima-nova",helvetica,arial,sans-serif; $body_font_color: #6a6a6a; $body_font_light: #b3aeae; +$text_accent: #1be223; $archive_p_line_height: 1.6; //$light; diff --git a/design/sass/_notes.scss b/design/sass/_notes.scss new file mode 100644 index 0000000..09d234a --- /dev/null +++ b/design/sass/_notes.scss @@ -0,0 +1,71 @@ +.note-title { + @include fontsize(22); +} +.note-header { + @extend %clearfix; +} +.note-header-float { + width: 30%; + float: right; + text-align: right; +} +.note-time, .note-url { + text-align: right; + @include fancy-sans; + @include fontsize(13); +} +.note-container { + @include constrain(80%); +} +#note-body { + @include fancy-sans; + @include fontsize(15); +} +.inactive { + .ql-editor { + padding:0; + line-height:inherit; + p { + padding:inherit; + margin-bottom:10px; + } + } + .ql-toolbar { + display:none; + } + .ql-container.ql-snow { + border:none; + font-family:inherit; + font-size:inherit; + } +} +.note-list-container { + max-width: 300px; + ul { + padding: 0; + list-style-type: none; + } + li { + @include fontsize(13); + height: 4.5rem; + box-shadow: 0 -1px 0 #e7e2ee inset; + margin: 0; + a { + color: lighten($body_font_color, 15); + text-decoration: none; + } + } + h4 { + @include fontsize(15); + margin: 0; + padding: 8px 0 8px 6px; + font-weight: normal; + overflow: hidden; + white-space: nowrap; + } + .note-preview { + padding-left: 6px; + overflow: hidden; + white-space: nowrap; + } +} diff --git a/design/sass/screenv1.scss b/design/sass/screenv1.scss index 73a1fd1..d54027e 100644 --- a/design/sass/screenv1.scss +++ b/design/sass/screenv1.scss @@ -6,3 +6,5 @@ @import "_footer.scss"; @import "_forms.scss"; @import "_modal.scss"; +@import "_breadcrumbs.scss"; +@import "_notes.scss"; diff --git a/design/templates/base.html b/design/templates/base.html index 6b6ef8c..f14e5df 100644 --- a/design/templates/base.html +++ b/design/templates/base.html @@ -1,4 +1,4 @@ -<!DOCTYPE html> +{% load static %}<!DOCTYPE html> <!--[if lt IE 8]> <html class="lte8"> <![endif]--> <!--[if IE 8]> <html class="ie8 lte8"> <![endif]--> <!--[if IE 9]> <html class="ie9"> <![endif]--> @@ -10,9 +10,10 @@ <meta property="og:description" content="Note taking for writers"> <meta property="og:site_name" content="Notes"> <meta property="og:image" content=""> -<link rel="stylesheet" href="/media/screenv1.css?{{now}}" type="text/css"> +<link rel="stylesheet" href="/media/screenv1.css?{%now "u"%}" type="text/css"> +{% block extrastyles %}{%endblock%} <link rel="icon" type="image/png" href=""> -<link rel="manifest" href="/webmanifest.json"> +<!--<link rel="manifest" href="/webmanifest.json">--> <link rel="apple-touch-icon" sizes="256x256" href=""> </head> <body class="{% block bodyclass %}{% endblock %}"> @@ -37,11 +38,17 @@ </header> </div> <div class="wrapper"> + <ul class="breadcrumb" id="breadcrumbs" itemscope itemtype="http://data-vocabulary.org/Breadcrumb"> + <li> + <a href="/" title="home" itemprop="url"><span itemprop="title">Home</span></a> + </li> + {% block breadcrumbs %}{% endblock %} + </ul> {% block content %} {% endblock %} </div> <footer> - <p>© Luxagraf Software. Problems or questions? Contact <a href="{% url 'pages' slug='terms-of-service' %}" title="">support@notes.tld</a>.</p> + <p>© Arkhangelsk Software. Problems or questions? Contact <a href="{% url 'pages' slug='terms-of-service' %}" title="">support@notes.tld</a>.</p> <nav> <ul> <li><a href="{% url 'pages' slug='terms-of-service' %}" title="">Terms of Service</a></li> @@ -56,21 +63,11 @@ {% block extra %} {%endblock%} <script async src="/media/js/package.min.js"></script> +{% block jsinclude %}{%endblock%} <script> // Waiting for the DOM to load document.addEventListener("DOMContentLoaded", function () { - // Select your overlay trigger - var trigger = document.querySelector('#overlay-trigger'); - trigger.addEventListener('click', function(e){ - e.preventDefault(); - novicell.overlay.create({ - 'selector': trigger.getAttribute('data-element'), - 'class': 'selector-overlay', - "onCreate": function() { console.log('created'); }, - "onLoaded": function() { console.log('loaded'); }, - "onDestroy": function() { console.log('Destroyed'); } - }); - }); + {% block jsdomready %}{%endblock%} }); </script> </body> diff --git a/design/templates/notes/create.html b/design/templates/notes/create.html deleted file mode 100644 index 3bd765d..0000000 --- a/design/templates/notes/create.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends 'base.html' %} -{% block content %} -<main> - <h1>Create a new note</h1> -<form action="" method="post"> -{% csrf_token %} -{{ form.non_field_errors }} -{% for field in form %} -<fieldset {% if field.errors %}class="error"{%endif%}> -{{field.label_tag}} -{{field}} -{% if field.errors %}{{field.errors}}{% endif %} -</fieldset> -{% endfor %} -<p><input class="btn" value="submit" type="submit" /></p> -</form> -</main> -{% endblock %} diff --git a/design/templates/notes/notes_create.html b/design/templates/notes/notes_create.html new file mode 100644 index 0000000..9bbdb26 --- /dev/null +++ b/design/templates/notes/notes_create.html @@ -0,0 +1,52 @@ +{% extends 'base.html' %} + +{% block extrastyles %} +<link rel="stylesheet" href="/media/quill.snow.css" /> +{% endblock %} +{% block content %} +<main> + <h1>Create a new note</h1> + <form id="new-note-form" action="{% url 'notes:note-create' %}" method="post"> +{% csrf_token %} +{{ form.non_field_errors }} +{% for field in form %} +<fieldset class="{% if field.errors %}error {%endif%}{% if field.name == 'body_qjson' or field.name == 'body_html' %}hide {%endif%}" id="fs-{{field.name}}" > +{{field.label_tag}} +{{field}} +{% if field.errors %}{{field.errors}}{% endif %} +</fieldset> +{% if field.name == 'body_qjson' %} +<div id="q-container"> + <div id="note-body"></div> +</div> +{% endif %} +{% endfor %} +<p><input class="btn btn-inline" value="submit" type="submit" /></p> +</form> +</main> +{% endblock %} + +{% block jsinclude %} +<script src="/media/js/highlight.pack.js"></script> +<script src="/media/js/quill.min.js"></script> +{% endblock %} + +<script> +{% block jsdomready %} + var note_text = document.getElementById('id_body_text'); + note_text.innerHTML = "q"; + var plaintext = document.getElementById("fs-body_text"); + plaintext.classList.add('hide') + initQuill("#note-body"); + var form = document.getElementById('new-note-form'); + console.log(form); + form.onsubmit = function() { + var note_qjson = document.getElementById('id_body_qjson'); + note_qjson.innerHTML= JSON.stringify(window.quill.getContents()); + var note_html = document.getElementById('id_body_html'); + note_html.innerHTML = window.quill.root.innerHTML; + var note_text = document.getElementById('id_body_text'); + note_text.innerHTML = window.quill.getText(); + }; +{% endblock %} +</script> diff --git a/design/templates/notes/notes_detail.html b/design/templates/notes/notes_detail.html new file mode 100644 index 0000000..bf46ab8 --- /dev/null +++ b/design/templates/notes/notes_detail.html @@ -0,0 +1,78 @@ +{% extends 'base.html' %} +{% block extrastyles %} +<link rel="stylesheet" href="/media/quill.snow.css" /> +{% endblock %} +{% block breadcrumbs %} +<li><a href="{%url 'notes:note-list' user.username %}">Notes</a></li> +{% endblock %} + +{% block content %} +<main> + <article class="note-container"> + <header class="note-header"> + <button class="hide btn btn-accent" id="edit-toggle-btn">Edit</button> + <div class="note-header-float"> + <h2 class="note-time">{{object.date_created|date:"M d, Y"}}</h2> + {% if object.url %}<h3 class="note-url"><a class="btn btn-small btn-subtle" href="{{object.url}}">Source</a><a class="btn btn-small btn-subtle left-margin-2" href="object.cache">Archive</a></h3>{% endif %} + </div> + </header> + <h1 id="note-title" class="note-title">{{object.title}}</h1> + <div id="q-container" class="inactive"><div id="note-body">{% if object.body_html %}{{object.body_html|safe}}{%else%}{{object.body_text}}{%endif%}</div></div> + <form action="" method="post" id="note-edit-form">{% csrf_token %} + {% for field in form %}{% if field.name in "title body_text" %} + <div class="hide">{{field}}</div> + {% endif%}{% endfor %} + <input id="btn-js-hide" type="submit" class="btn sm" value="Save" > + </form> + </article> + <aside class="note-list-container"> + <div class=""> + <ul>{% for obj in notes_list %} + <li> + <a href="{% url 'notes:note-detail' user.username obj.slug %}"> + <h4>{{obj.title}}</h4> + <div class="note-preview">{{obj.body_text|truncatewords:12}}</div> + </a> + </li> + {% endfor %}</ul> + </div> + </aside> +</main> +{% endblock %} + +{% block jsinclude %} +<script src="/media/js/highlight.pack.js"></script> +<script src="/media/js/quill.min.js"></script> +{% endblock %} + <script> +{% block jsdomready %} + var btn = document.getElementById("edit-toggle-btn"), + qcontainer = document.getElementById('q-container'), + title = document.getElementById('note-title'), + form = document.getElementById('note-edit-form'), + note_html = document.createElement('textarea'), + note_qjson = document.createElement('textarea'); + + window.editing = false; + window.quillchange = false; + + btn.classList.remove('hide'); + initQuill("#note-body"); + note_html.setAttribute('name', 'body_html'); + note_html.setAttribute('class', 'hide'); + note_html.setAttribute('id', 'id_body_html'); + note_qjson.setAttribute('name', 'body_qjson'); + note_qjson.setAttribute('id', 'id_body_qjson'); + note_qjson.setAttribute('class', 'hide'); + form.appendChild(note_html); + form.appendChild(note_qjson); + document.getElementById("btn-js-hide").classList.add("hide"); + btn.addEventListener('click', function(){edit_note(this, title, qcontainer, window.quill, "{% url 'notes-api-detail' object.pk %}" )}, false) + +{%endblock%} + </script> + +'indent +'align +'direction +'code-block diff --git a/design/templates/notes/notes_list.html b/design/templates/notes/notes_list.html index 4451588..8066369 100644 --- a/design/templates/notes/notes_list.html +++ b/design/templates/notes/notes_list.html @@ -3,7 +3,7 @@ <main> <h1> Notes</h1> <ul>{% for obj in object_list %} - <li><a href="{{obj.get_absolute_url}}">{{obj}}</a></li> + <li><a href="{% url 'notes:note-detail' user.username obj.slug %}">{{obj}}</a></li> {% endfor %}</ul> </main> {% endblock %} diff --git a/design/templates/pages/page.html b/design/templates/pages/page.html index 91d4732..3feff75 100644 --- a/design/templates/pages/page.html +++ b/design/templates/pages/page.html @@ -13,3 +13,19 @@ </div> {% endif %} {%endblock%} +{% block jsdomready %} +{% if login_form %} + // Select your overlay trigger + var trigger = document.querySelector('#overlay-trigger'); + trigger.addEventListener('click', function(e){ + e.preventDefault(); + novicell.overlay.create({ + 'selector': trigger.getAttribute('data-element'), + 'class': 'selector-overlay', + "onCreate": function() { console.log('created'); }, + "onLoaded": function() { console.log('loaded'); }, + "onDestroy": function() { console.log('Destroyed'); } + }); + }); +{% endif %} +{%endblock%} @@ -3,8 +3,9 @@ import os minified = "" for filename in os.listdir('scripts'): - with open(os.path.join('scripts', filename)) as js_file: - minified += jsmin(js_file.read()) + if not os.path.isdir(os.path.join('scripts', filename)): + with open(os.path.join('scripts', filename)) as js_file: + minified += jsmin(js_file.read()) with open('media/js/package.min.js', 'w') as jscompressed: jscompressed.write(minified) jscompressed.close() diff --git a/scripts/util.js b/scripts/util.js index d70c012..99a5ef3 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -1,14 +1,56 @@ +function edit_note(btn, title, qcontainer, quill, url){ + console.log(editing); + var formElement = document.querySelector("form"); + if (editing === false) { + title.setAttribute("contenteditable", true); + title.classList.add('highlight') + qcontainer.classList.remove('inactive') + quill.enable(true); + btn.innerHTML = "Save" + editing = true; + } else { + if (window.quillchange === true) { + var form_note_title = document.getElementById('id_title'); + var note_html = document.getElementById('id_body_html'); + var note_qjson = document.getElementById('id_body_qjson'); + form_note_title.value = title.innerHTML; + note_html.innerHTML = quill.root.innerHTML; + note_qjson.innerHTML = JSON.stringify(quill.getContents()); + var request = new XMLHttpRequest(); + request.open("PATCH", url); + var csrftoken = Cookies.get('csrftoken'); + request.setRequestHeader("X-CSRFToken", csrftoken) + request.onload = function() { + if (request.status >= 200 && request.status < 400) { + console.log(request); + window.quillchange = false; + } else { + console.log(request); + console.log("server error"); + } + }; + request.onerror = function() { + console.log("error on request"); + }; + request.send(new FormData(formElement)); + } + title.setAttribute("contenteditable", false); + title.classList.remove('highlight') + qcontainer.classList.add('inactive'); + quill.enable(false); + btn.innerHTML = "Edit" + document.body.focus(); + editing = false; + } + return false; +} + + function get_login_form() { var request = new XMLHttpRequest(); request.open('GET', '/login/', true); request.onload = function() { if (request.status >= 200 && request.status < 400) { - var data = - for(var i in data) { - var u = data[i]['fields']['part_number'] + ' - ' + data[i]['fields']['part_name'] - choices.push({ value: String(data[i].pk), label: u, }); - } - populateParts(choices); } else { console.log("server error"); } @@ -18,3 +60,25 @@ function get_login_form() { }; request.send(); } + +//Global init for Quill +function initQuill(el) { + window.quill = new Quill(el, { + modules: { + syntax: true, // Include syntax module + toolbar: [ + [{ header: [1, 2, 3, 4, false] }], + ['bold', 'italic', 'underline', 'blockquote'], + [{ 'list': 'bullet'}, { 'list': 'ordered'},{ 'list': 'check'} ], + ['link', 'code-block', 'image', 'video', 'formula',], + [{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme + [{ 'font': [] }], + ] + }, + theme: 'snow', + enable: false + }); + window.quill.on('text-change', function() { + window.quillchange = true; + }); +} |