diff --git a/blango/__pycache__/__init__.cpython-36.pyc b/blango/__pycache__/__init__.cpython-36.pyc
new file mode 100644
index 0000000000..a13d8b5dbf
Binary files /dev/null and b/blango/__pycache__/__init__.cpython-36.pyc differ
diff --git a/blango/__pycache__/settings.cpython-36.pyc b/blango/__pycache__/settings.cpython-36.pyc
new file mode 100644
index 0000000000..a3b8accf37
Binary files /dev/null and b/blango/__pycache__/settings.cpython-36.pyc differ
diff --git a/blango/__pycache__/urls.cpython-36.pyc b/blango/__pycache__/urls.cpython-36.pyc
new file mode 100644
index 0000000000..4f25590de6
Binary files /dev/null and b/blango/__pycache__/urls.cpython-36.pyc differ
diff --git a/blango/__pycache__/wsgi.cpython-36.pyc b/blango/__pycache__/wsgi.cpython-36.pyc
new file mode 100644
index 0000000000..b430092578
Binary files /dev/null and b/blango/__pycache__/wsgi.cpython-36.pyc differ
diff --git a/blango/settings.py b/blango/settings.py
index f9209bef27..d40f036828 100644
--- a/blango/settings.py
+++ b/blango/settings.py
@@ -1,7 +1,7 @@
"""
Django settings for blango project.
-Generated by 'django-admin startproject' using Django 3.2.7.
+Generated by 'django-admin startproject' using Django 3.2.5.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/
@@ -10,7 +10,13 @@
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
+import os
from pathlib import Path
+from configurations import Configuration
+from configurations import values
+from datetime import timedelta
+
+# class Dev:
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -20,13 +26,19 @@
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = 'django-insecure-+sn%dpa!086+g+%44z9*^j^q-u4n!j(#wl)x9a%_1op@zz2+1-'
+SECRET_KEY = 'django-insecure-&!=9y436&^-bc$qia-mxngyf&xx)@ct)8lu@)=qxg_07-=z01w'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
-ALLOWED_HOSTS = []
-
+ALLOWED_HOSTS = ['*']
+X_FRAME_OPTIONS = 'ALLOW-FROM ' + os.environ.get('CODIO_HOSTNAME') + '-8000.codio.io'
+CSRF_COOKIE_SAMESITE = None
+CSRF_TRUSTED_ORIGINS = [os.environ.get('CODIO_HOSTNAME') + '-8000.codio.io']
+CSRF_COOKIE_SECURE = True
+SESSION_COOKIE_SECURE = True
+CSRF_COOKIE_SAMESITE = 'None'
+SESSION_COOKIE_SAMESITE = 'None'
# Application definition
@@ -37,16 +49,31 @@
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
+ 'django.contrib.sites',
+ 'blog',
+ 'crispy_forms',
+ 'crispy_bootstrap5',
+ 'debug_toolbar',
+ 'blango_auth',
+ 'allauth',
+ 'allauth.account',
+ 'allauth.socialaccount',
+ 'allauth.socialaccount.providers.google',
+ 'rest_framework.authtoken',
+ 'drf_yasg',
+ 'django_filters',
+ 'versatileimagefield',
]
MIDDLEWARE = [
+ 'debug_toolbar.middleware.DebugToolbarMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
- 'django.middleware.csrf.CsrfViewMiddleware',
+# 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
- 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'blango.urls'
@@ -54,7 +81,7 @@
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
- 'DIRS': [],
+ "DIRS": [BASE_DIR / "templates"],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@@ -99,14 +126,21 @@
},
]
+PASSWORD_HASHERS = [
+ 'django.contrib.auth.hashers.Argon2PasswordHasher',
+ 'django.contrib.auth.hashers.PBKDF2PasswordHasher',
+ 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
+ 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
+]
+
+
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
-TIME_ZONE = 'UTC'
-
+TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
@@ -123,3 +157,88 @@
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
+CRISPY_TEMPLATE_PACK = "bootstrap5"
+
+# CACHES = {
+# "default": {
+# "BACKEND": "django.core.cache.backends.db.DatabaseCache",
+# "LOCATION": "my_cache_table",
+# }
+# }
+INTERNAL_IPS = ["192.168.10.226"]
+AUTH_USER_MODEL = "blango_auth.User"
+ACCOUNT_ACTIVATION_DAYS = 7
+EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
+
+#
+
+# class Prod(Dev):
+# DEBUG = False
+# SECRET_KEY = values.SecretValue()
+# ALLOWED_HOSTS = values.ListValue(["localhost", "0.0.0.0", ".codio.io"])
+# DATABASES = {
+# "default": dj_database_url.config(default=f"sqlite:///{BASE_DIR}/db.sqlite3"),
+# "alternative": dj_database_url.config(
+# "ALTERNATIVE_DATABASE_URL",
+# default=f"sqlite:///{BASE_DIR}/alternative_db.sqlite3",
+# ),
+# }
+
+SITE_ID = 1
+
+ACCOUNT_USER_MODEL_USERNAME_FIELD = None
+ACCOUNT_EMAIL_REQUIRED = True
+ACCOUNT_USERNAME_REQUIRED = False
+ACCOUNT_AUTHENTICATION_METHOD = "email"
+REST_FRAMEWORK = {
+ "DEFAULT_AUTHENTICATION_CLASSES": [
+ # "rest_framework.permissions.IsAuthenticated",
+ "rest_framework.authentication.BasicAuthentication",
+ "rest_framework.authentication.SessionAuthentication",
+ "rest_framework.authentication.TokenAuthentication",
+ "rest_framework_simplejwt.authentication.JWTAuthentication"
+ # 'rest_framework.permissions.IsAuthenticated'
+ ],
+ # "DEFAULT_PERMISSION_CLASSES": [
+ # "rest_framework.permissions.IsAuthenticatedOrReadOnly"
+ # ],
+
+ "DEFAULT_THROTTLE_CLASSES": [
+ "blog.api.throttling.AnonSustainedThrottle",
+ "blog.api.throttling.AnonBurstThrottle",
+ "blog.api.throttling.UserSustainedThrottle",
+ "blog.api.throttling.UserBurstThrottle",
+ ],
+ "DEFAULT_THROTTLE_RATES": {
+ "anon_sustained": "500/day",
+ "anon_burst": "10/minute",
+ "user_sustained": "5000/day",
+ "user_burst": "100/minute",
+ },
+ "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
+ "PAGE_SIZE": 100,
+ "DEFAULT_FILTER_BACKENDS": [
+ "django_filters.rest_framework.DjangoFilterBackend",
+ "rest_framework.filters.OrderingFilter"
+ ],
+
+
+}
+
+
+SWAGGER_SETTINGS = {
+ "SECURITY_DEFINITIONS": {
+ "Token": {"type": "apiKey", "name": "Authorization", "in": "header"},
+ "Basic": {"type": "basic"},
+ }
+}
+
+SIMPLE_JWT = {
+"ACCESS_TOKEN_LIFETIME": timedelta(days=1),
+"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
+}
+
+MEDIA_ROOT = BASE_DIR / "media"
+MEDIA_URL = "/media/"
+
diff --git a/blango/templates/base.html b/blango/templates/base.html
new file mode 100644
index 0000000000..6a1e1a602f
--- /dev/null
+++ b/blango/templates/base.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Hello, world!
+
+
+ Hello, world!
+ {% block content %}
+
+ {% endblock %}
+
+
+
diff --git a/blango/templates/blog/index.html b/blango/templates/blog/index.html
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/blango/templates/rest_framework/api.html b/blango/templates/rest_framework/api.html
new file mode 100644
index 0000000000..24eacc607c
--- /dev/null
+++ b/blango/templates/rest_framework/api.html
@@ -0,0 +1,17 @@
+{% extends "rest_framework/base.html" %}
+{% block title %}{% if name %}{{ name }} – {% endif %} Blango REST API{% endblock %}
+
+{% block branding %}
+
+ Blango REST API
+
+{% endblock %}
+
+{% block style %}
+ {{ block.super }}
+
+{% endblock %}
diff --git a/blango/urls.py b/blango/urls.py
index cde05802f9..6738a83097 100644
--- a/blango/urls.py
+++ b/blango/urls.py
@@ -1,3 +1,5 @@
+import blog.views
+
"""blango URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
@@ -13,9 +15,87 @@
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
+import debug_toolbar
+from django.conf import settings
from django.contrib import admin
-from django.urls import path
+from django.urls import path, include
+
+import blog.views
+import blango_auth.views
+from django_registration.backends.activation.views import RegistrationView
+from blango_auth.forms import BlangoRegistrationForm
+from django.conf.urls.static import static
+
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+from django.urls import re_path
+
+
+from drf_yasg import openapi
+from drf_yasg.views import get_schema_view
+
+schema_view = get_schema_view(
+ openapi.Info(
+ title="Blango API",
+ default_version="v1",
+ description="API for Blango Blog",
+ ),
+ public=True,
+)
+
+
+
urlpatterns = [
path('admin/', admin.site.urls),
+ path("", blog.views.index),
+ path("ip/", blog.views.get_ip),
+ path("accounts/", include("django.contrib.auth.urls")),
+ path("accounts/profile/", blango_auth.views.profile, name="profile"),
+ path('accounts/', include('django_registration.backends.activation.urls')),
+ path(
+ "accounts/register/",
+ RegistrationView.as_view(form_class=BlangoRegistrationForm),
+ name="django_registration_register",
+ ),
+ path("accounts/", include("django_registration.backends.activation.urls")),
+ path("accounts/", include("allauth.urls")),
+ path("accounts/", include("allauth.urls")),
+ path("accounts/", include("allauth.urls")),
+ # for function based view below
+ # path("api/v1/", include("blog.api_urls")),
+ path("api/v1/", include("blog.api.urls")),
+ path("post-table/", blog.views.post_table, name="blog-post-table"),
+
+
+ # path("post//", blog.views.post_detail, name="blog-post-detail")
]
+
+# urlpatterns += [
+# # ...
+# re_path(
+# r"^swagger(?P\.json|\.yaml)$",
+# schema_view.without_ui(cache_timeout=0),
+# name="schema-json",
+# ),
+# # ...
+# ]
+urlpatterns += [
+ # ...
+ path(
+ r"^swagger/$",
+ schema_view.with_ui("swagger", cache_timeout=0),
+ name="schema-swagger-ui",
+ ),
+ # ...
+]
+
+
+
+if settings.DEBUG:
+ urlpatterns += [
+ path("__debug__/", include(debug_toolbar.urls)),
+ ]+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
\ No newline at end of file
diff --git a/blango/wsgi.py b/blango/wsgi.py
index 83565cf12c..a246900546 100644
--- a/blango/wsgi.py
+++ b/blango/wsgi.py
@@ -12,5 +12,7 @@
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'blango.settings')
+os.environ.setdefault("DJANGO_CONFIGURATION", "Prod")
+
application = get_wsgi_application()
diff --git a/blango_auth/__init__.py b/blango_auth/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/blango_auth/__pycache__/__init__.cpython-36.pyc b/blango_auth/__pycache__/__init__.cpython-36.pyc
new file mode 100644
index 0000000000..64a7ea8fd5
Binary files /dev/null and b/blango_auth/__pycache__/__init__.cpython-36.pyc differ
diff --git a/blango_auth/__pycache__/admin.cpython-36.pyc b/blango_auth/__pycache__/admin.cpython-36.pyc
new file mode 100644
index 0000000000..12f42e315e
Binary files /dev/null and b/blango_auth/__pycache__/admin.cpython-36.pyc differ
diff --git a/blango_auth/__pycache__/apps.cpython-36.pyc b/blango_auth/__pycache__/apps.cpython-36.pyc
new file mode 100644
index 0000000000..5f447e5c5b
Binary files /dev/null and b/blango_auth/__pycache__/apps.cpython-36.pyc differ
diff --git a/blango_auth/__pycache__/forms.cpython-36.pyc b/blango_auth/__pycache__/forms.cpython-36.pyc
new file mode 100644
index 0000000000..fa32d56099
Binary files /dev/null and b/blango_auth/__pycache__/forms.cpython-36.pyc differ
diff --git a/blango_auth/__pycache__/models.cpython-36.pyc b/blango_auth/__pycache__/models.cpython-36.pyc
new file mode 100644
index 0000000000..cb36dcadf6
Binary files /dev/null and b/blango_auth/__pycache__/models.cpython-36.pyc differ
diff --git a/blango_auth/__pycache__/tests.cpython-36.pyc b/blango_auth/__pycache__/tests.cpython-36.pyc
new file mode 100644
index 0000000000..e330fe69b3
Binary files /dev/null and b/blango_auth/__pycache__/tests.cpython-36.pyc differ
diff --git a/blango_auth/__pycache__/views.cpython-36.pyc b/blango_auth/__pycache__/views.cpython-36.pyc
new file mode 100644
index 0000000000..60b3d7acc6
Binary files /dev/null and b/blango_auth/__pycache__/views.cpython-36.pyc differ
diff --git a/blango_auth/admin.py b/blango_auth/admin.py
new file mode 100644
index 0000000000..a2fd9c75eb
--- /dev/null
+++ b/blango_auth/admin.py
@@ -0,0 +1,41 @@
+from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin
+from blango_auth.models import User
+from django.utils.translation import gettext_lazy as _
+
+# Register your models here.
+#admin.site.register(User, UserAdmin)
+
+
+class BlangoUserAdmin(UserAdmin):
+ fieldsets = (
+ (None, {"fields": ("email", "password")}),
+ (_("Personal info"), {"fields": ("first_name", "last_name")}),
+ (
+ _("Permissions"),
+ {
+ "fields": (
+ "is_active",
+ "is_staff",
+ "is_superuser",
+ "groups",
+ "user_permissions",
+ )
+ },
+ ),
+ (_("Important dates"), {"fields": ("last_login", "date_joined")}),
+ )
+ add_fieldsets = (
+ (
+ None,
+ {
+ "classes": ("wide",),
+ "fields": ("email", "password1", "password2"),
+ },
+ ),
+ )
+ list_display = ("email", "first_name", "last_name", "is_staff")
+ search_fields = ("email", "first_name", "last_name")
+ ordering = ("email",)
+
+admin.site.register(User, BlangoUserAdmin)
diff --git a/blango_auth/apps.py b/blango_auth/apps.py
new file mode 100644
index 0000000000..3619a45e56
--- /dev/null
+++ b/blango_auth/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class BlangoAuthConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'blango_auth'
diff --git a/blango_auth/forms.py b/blango_auth/forms.py
new file mode 100644
index 0000000000..80c9376fa5
--- /dev/null
+++ b/blango_auth/forms.py
@@ -0,0 +1,15 @@
+from crispy_forms.helper import FormHelper
+from crispy_forms.layout import Submit
+from django_registration.forms import RegistrationForm
+
+from blango_auth.models import User
+
+
+class BlangoRegistrationForm(RegistrationForm):
+ class Meta(RegistrationForm.Meta):
+ model = User
+
+ def __init__(self, *args, **kwargs):
+ super(BlangoRegistrationForm, self).__init__(*args, **kwargs)
+ self.helper = FormHelper()
+ self.helper.add_input(Submit("submit", "Register"))
diff --git a/blango_auth/migrations/0001_initial.py b/blango_auth/migrations/0001_initial.py
new file mode 100644
index 0000000000..bbac34291a
--- /dev/null
+++ b/blango_auth/migrations/0001_initial.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.2.6 on 2024-01-02 01:25
+
+import django.contrib.auth.models
+import django.contrib.auth.validators
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='User',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('password', models.CharField(max_length=128, verbose_name='password')),
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+ ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+ ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
+ ],
+ options={
+ 'verbose_name': 'user',
+ 'verbose_name_plural': 'users',
+ 'abstract': False,
+ },
+ managers=[
+ ('objects', django.contrib.auth.models.UserManager()),
+ ],
+ ),
+ ]
diff --git a/blango_auth/migrations/0002_auto_20240102_0232.py b/blango_auth/migrations/0002_auto_20240102_0232.py
new file mode 100644
index 0000000000..bcff315ddb
--- /dev/null
+++ b/blango_auth/migrations/0002_auto_20240102_0232.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.6 on 2024-01-02 02:32
+
+import blango_auth.models
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blango_auth', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterModelManagers(
+ name='user',
+ managers=[
+ ('objects', blango_auth.models.BlangoUserManager()),
+ ],
+ ),
+ migrations.RemoveField(
+ model_name='user',
+ name='username',
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='email',
+ field=models.EmailField(max_length=254, unique=True, verbose_name='email address'),
+ ),
+ ]
diff --git a/blango_auth/migrations/__init__.py b/blango_auth/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/blango_auth/migrations/__pycache__/0001_initial.cpython-36.pyc b/blango_auth/migrations/__pycache__/0001_initial.cpython-36.pyc
new file mode 100644
index 0000000000..9a39432c92
Binary files /dev/null and b/blango_auth/migrations/__pycache__/0001_initial.cpython-36.pyc differ
diff --git a/blango_auth/migrations/__pycache__/0002_auto_20240102_0232.cpython-36.pyc b/blango_auth/migrations/__pycache__/0002_auto_20240102_0232.cpython-36.pyc
new file mode 100644
index 0000000000..5e56708559
Binary files /dev/null and b/blango_auth/migrations/__pycache__/0002_auto_20240102_0232.cpython-36.pyc differ
diff --git a/blango_auth/migrations/__pycache__/__init__.cpython-36.pyc b/blango_auth/migrations/__pycache__/__init__.cpython-36.pyc
new file mode 100644
index 0000000000..609d6de542
Binary files /dev/null and b/blango_auth/migrations/__pycache__/__init__.cpython-36.pyc differ
diff --git a/blango_auth/models.py b/blango_auth/models.py
new file mode 100644
index 0000000000..d651038b8d
--- /dev/null
+++ b/blango_auth/models.py
@@ -0,0 +1,53 @@
+from django.db import models
+from django.contrib.auth.models import AbstractUser
+from django.utils.translation import gettext_lazy as _
+
+# Create your models here.
+
+
+from django.db import models
+from django.contrib.auth.models import AbstractUser, UserManager
+
+class BlangoUserManager(UserManager):
+ def _create_user(self, email, password, **extra_fields):
+ if not email:
+ raise ValueError("Email must be set")
+ email = self.normalize_email(email)
+ user = self.model(email=email, **extra_fields)
+ user.set_password(password)
+ user.save(using=self._db)
+ return user
+
+ def create_user(self, email, password=None, **extra_fields):
+ extra_fields.setdefault("is_staff", False)
+ extra_fields.setdefault("is_superuser", False)
+ return self._create_user(email, password, **extra_fields)
+
+ def create_superuser(self, email, password, **extra_fields):
+ extra_fields.setdefault("is_staff", True)
+ extra_fields.setdefault("is_superuser", True)
+
+ if extra_fields.get("is_staff") is not True:
+ raise ValueError("Superuser must have is_staff=True.")
+ if extra_fields.get("is_superuser") is not True:
+ raise ValueError("Superuser must have is_superuser=True.")
+
+ return self._create_user(email, password, **extra_fields)
+
+
+
+class User(AbstractUser):
+ username = None
+ email = models.EmailField(
+ _("email address"),
+ unique=True,
+ )
+
+ objects = BlangoUserManager()
+
+ USERNAME_FIELD = "email"
+ REQUIRED_FIELDS = []
+
+ def __str__(self):
+ return self.email
+
diff --git a/blango_auth/templates/blango_auth/django_registration/activateion_email_body.txt b/blango_auth/templates/blango_auth/django_registration/activateion_email_body.txt
new file mode 100644
index 0000000000..ed9a366a2c
--- /dev/null
+++ b/blango_auth/templates/blango_auth/django_registration/activateion_email_body.txt
@@ -0,0 +1,11 @@
+{{ scheme }}://{{ request.get_host }}{% url "django_registration_activate" activation_key %}
+Hi,
+
+You registered for Blango, but you need to activate your account within {{ expiration_days }} days.
+
+To do that, please visit this page:
+
+{{ scheme }}://{{ request.get_host }}{% url "django_registration_activate" activation_key %}
+
+Thanks,
+The Blango Team
diff --git a/blango_auth/templates/blango_auth/django_registration/activateion_error.message b/blango_auth/templates/blango_auth/django_registration/activateion_error.message
new file mode 100644
index 0000000000..07a3f7adbb
--- /dev/null
+++ b/blango_auth/templates/blango_auth/django_registration/activateion_error.message
@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+{% load crispy_forms_tags blog_extras %}
+{% block title %}Activation Failed{% endblock %}
+{% block content %}
+{% row "justify-content-center" %}
+ {% col "col-md-6" %}
+ Activation Failed
+ Sorry, we couldn't activate your account.
+ {{ activation_error.message }}
+ {% endcol %}
+{% endrow %}
+{% endblock %}
diff --git a/blango_auth/templates/blango_auth/django_registration/activation_complete.txt b/blango_auth/templates/blango_auth/django_registration/activation_complete.txt
new file mode 100644
index 0000000000..0aa403fca1
--- /dev/null
+++ b/blango_auth/templates/blango_auth/django_registration/activation_complete.txt
@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+{% load crispy_forms_tags blog_extras %}
+{% block title %}Activation Complete{% endblock %}
+{% block content %}
+{% row "justify-content-center" %}
+ {% col "col-md-6" %}
+ Activation Complete
+ Your account is now activated! You can now log in and use Blango.
+ Log In
+ {% endcol %}
+{% endrow %}
+{% endblock %}
diff --git a/blango_auth/templates/blango_auth/django_registration/activation_email_subject.txt b/blango_auth/templates/blango_auth/django_registration/activation_email_subject.txt
new file mode 100644
index 0000000000..28cb171627
--- /dev/null
+++ b/blango_auth/templates/blango_auth/django_registration/activation_email_subject.txt
@@ -0,0 +1 @@
+Activate your Blango account! You have {{ expiration_days }} days!
diff --git a/blango_auth/templates/blango_auth/django_registration/registration_closed.html b/blango_auth/templates/blango_auth/django_registration/registration_closed.html
new file mode 100644
index 0000000000..e52f0ee429
--- /dev/null
+++ b/blango_auth/templates/blango_auth/django_registration/registration_closed.html
@@ -0,0 +1,10 @@
+{% extends "base.html" %}
+{% load crispy_forms_tags blog_extras %}
+{% block title %}Registration Closed{% endblock %}
+{% block content %}
+{% row "justify-content-center" %}
+ {% col "col-md-6" %}
+ Registration is currently closed
+ {% endcol %}
+{% endrow %}
+{% endblock %}
diff --git a/blango_auth/templates/blango_auth/django_registration/registration_complete.html b/blango_auth/templates/blango_auth/django_registration/registration_complete.html
new file mode 100644
index 0000000000..9050bc8b19
--- /dev/null
+++ b/blango_auth/templates/blango_auth/django_registration/registration_complete.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% load crispy_forms_tags blog_extras %}
+{% block title %}Registration Successful{% endblock %}
+{% block content %}
+{% row "justify-content-center" %}
+ {% col "col-md-6" %}
+ Your registration was successful
+ Check your email to validate your account.
+ {% endcol %}
+{% endrow %}
+{% endblock %}
diff --git a/blango_auth/templates/blango_auth/django_registration/registration_form.html b/blango_auth/templates/blango_auth/django_registration/registration_form.html
new file mode 100644
index 0000000000..962ef2c759
--- /dev/null
+++ b/blango_auth/templates/blango_auth/django_registration/registration_form.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% load crispy_forms_tags blog_extras %}
+{% block title %}Register for Blango{% endblock %}
+{% block content %}
+{% row "justify-content-center" %}
+ {% col "col-md-6" %}
+ Register for Blango
+ {% crispy form %}
+ {% endcol %}
+{% endrow %}
+{% endblock %}
diff --git a/blango_auth/templates/blango_auth/profile.html b/blango_auth/templates/blango_auth/profile.html
new file mode 100644
index 0000000000..c5cf4aadb7
--- /dev/null
+++ b/blango_auth/templates/blango_auth/profile.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% load blog_extras %}
+{% block title %}Blango Profile{% endblock %}
+{% block content %}
+{% row %}
+ {% col %}
+ Logged in as {{ request.user }}.
+ Log Out
+ {% endcol %}
+{% endrow %}
+{% endblock content %}
diff --git a/blango_auth/templates/registration/login.html b/blango_auth/templates/registration/login.html
new file mode 100644
index 0000000000..bc845a8814
--- /dev/null
+++ b/blango_auth/templates/registration/login.html
@@ -0,0 +1,30 @@
+{% extends "base.html" %}
+{% load crispy_forms_tags blog_extras %}
+{% block title %}Log In to Blango{% endblock %}
+{% block content %}
+
+ {% if next %}
+ {% if user.is_authenticated %}
+ Your account doesn't have access to this page. To proceed,
+ please login with an account that has access.
+ {% else %}
+ Please login to see this page.
+ {% endif %}
+ {% endif %}
+
+
+
+
+
+ Lost password?
+
+ Log in with Google
+
+
+
+{% endblock %}
diff --git a/blango_auth/tests.py b/blango_auth/tests.py
new file mode 100644
index 0000000000..7ce503c2dd
--- /dev/null
+++ b/blango_auth/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/blango_auth/views.py b/blango_auth/views.py
new file mode 100644
index 0000000000..84fb12280d
--- /dev/null
+++ b/blango_auth/views.py
@@ -0,0 +1,7 @@
+from django.contrib.auth.decorators import login_required
+from django.shortcuts import render
+
+
+@login_required
+def profile(request):
+ return render(request, "blango_auth/profile.html")
diff --git a/blog/__init__.py b/blog/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/blog/__pycache__/__init__.cpython-36.pyc b/blog/__pycache__/__init__.cpython-36.pyc
new file mode 100644
index 0000000000..f591ca21b5
Binary files /dev/null and b/blog/__pycache__/__init__.cpython-36.pyc differ
diff --git a/blog/__pycache__/admin.cpython-36.pyc b/blog/__pycache__/admin.cpython-36.pyc
new file mode 100644
index 0000000000..af514f04f9
Binary files /dev/null and b/blog/__pycache__/admin.cpython-36.pyc differ
diff --git a/blog/__pycache__/api_urls.cpython-36.pyc b/blog/__pycache__/api_urls.cpython-36.pyc
new file mode 100644
index 0000000000..ab3dad7b8e
Binary files /dev/null and b/blog/__pycache__/api_urls.cpython-36.pyc differ
diff --git a/blog/__pycache__/api_views.cpython-36.pyc b/blog/__pycache__/api_views.cpython-36.pyc
new file mode 100644
index 0000000000..1bd581b173
Binary files /dev/null and b/blog/__pycache__/api_views.cpython-36.pyc differ
diff --git a/blog/__pycache__/apps.cpython-36.pyc b/blog/__pycache__/apps.cpython-36.pyc
new file mode 100644
index 0000000000..48678c5c77
Binary files /dev/null and b/blog/__pycache__/apps.cpython-36.pyc differ
diff --git a/blog/__pycache__/models.cpython-36.pyc b/blog/__pycache__/models.cpython-36.pyc
new file mode 100644
index 0000000000..db50908f38
Binary files /dev/null and b/blog/__pycache__/models.cpython-36.pyc differ
diff --git a/blog/__pycache__/test_post_api.cpython-36.pyc b/blog/__pycache__/test_post_api.cpython-36.pyc
new file mode 100644
index 0000000000..1404be17af
Binary files /dev/null and b/blog/__pycache__/test_post_api.cpython-36.pyc differ
diff --git a/blog/__pycache__/test_tag_api.cpython-36.pyc b/blog/__pycache__/test_tag_api.cpython-36.pyc
new file mode 100644
index 0000000000..46e410192e
Binary files /dev/null and b/blog/__pycache__/test_tag_api.cpython-36.pyc differ
diff --git a/blog/__pycache__/tests.cpython-36.pyc b/blog/__pycache__/tests.cpython-36.pyc
new file mode 100644
index 0000000000..429c4b600a
Binary files /dev/null and b/blog/__pycache__/tests.cpython-36.pyc differ
diff --git a/blog/__pycache__/views.cpython-36.pyc b/blog/__pycache__/views.cpython-36.pyc
new file mode 100644
index 0000000000..017151da60
Binary files /dev/null and b/blog/__pycache__/views.cpython-36.pyc differ
diff --git a/blog/admin.py b/blog/admin.py
new file mode 100644
index 0000000000..02c9b965f8
--- /dev/null
+++ b/blog/admin.py
@@ -0,0 +1,16 @@
+from django.contrib import admin
+from blog.models import Tag, Post, Comment, AuthorProfile
+
+
+# Register your models here.
+admin.site.register(Tag)
+
+class PostAdmin(admin.ModelAdmin):
+ prepopulated_fields = {"slug": ("title",)}
+
+admin.site.register(Post, PostAdmin)
+admin.site.register(Comment)
+admin.site.register(AuthorProfile)
+
+
+
diff --git a/blog/api/__init__.py b/blog/api/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/blog/api/__pycache__/__init__.cpython-36.pyc b/blog/api/__pycache__/__init__.cpython-36.pyc
new file mode 100644
index 0000000000..f0594d0680
Binary files /dev/null and b/blog/api/__pycache__/__init__.cpython-36.pyc differ
diff --git a/blog/api/__pycache__/filters.cpython-36.pyc b/blog/api/__pycache__/filters.cpython-36.pyc
new file mode 100644
index 0000000000..ce068c7819
Binary files /dev/null and b/blog/api/__pycache__/filters.cpython-36.pyc differ
diff --git a/blog/api/__pycache__/permission.cpython-36.pyc b/blog/api/__pycache__/permission.cpython-36.pyc
new file mode 100644
index 0000000000..2820775988
Binary files /dev/null and b/blog/api/__pycache__/permission.cpython-36.pyc differ
diff --git a/blog/api/__pycache__/serializers.cpython-36.pyc b/blog/api/__pycache__/serializers.cpython-36.pyc
new file mode 100644
index 0000000000..624f160b57
Binary files /dev/null and b/blog/api/__pycache__/serializers.cpython-36.pyc differ
diff --git a/blog/api/__pycache__/throttling.cpython-36.pyc b/blog/api/__pycache__/throttling.cpython-36.pyc
new file mode 100644
index 0000000000..f1bd8fe3a6
Binary files /dev/null and b/blog/api/__pycache__/throttling.cpython-36.pyc differ
diff --git a/blog/api/__pycache__/urls.cpython-36.pyc b/blog/api/__pycache__/urls.cpython-36.pyc
new file mode 100644
index 0000000000..1a9fd37916
Binary files /dev/null and b/blog/api/__pycache__/urls.cpython-36.pyc differ
diff --git a/blog/api/__pycache__/views.cpython-36.pyc b/blog/api/__pycache__/views.cpython-36.pyc
new file mode 100644
index 0000000000..a0b30597eb
Binary files /dev/null and b/blog/api/__pycache__/views.cpython-36.pyc differ
diff --git a/blog/api/filters.py b/blog/api/filters.py
new file mode 100644
index 0000000000..592f87c848
--- /dev/null
+++ b/blog/api/filters.py
@@ -0,0 +1,31 @@
+from django_filters import rest_framework as filters
+
+from blog.models import Post
+
+
+class PostFilterSet(filters.FilterSet):
+ published_from = filters.DateFilter(
+ field_name="published_at", lookup_expr="gte", label="Published Date From"
+ )
+ published_to = filters.DateFilter(
+ field_name="published_at", lookup_expr="lte", label="Published Date To"
+ )
+ author_email = filters.CharFilter(
+ field_name="author__email",
+ lookup_expr="icontains",
+ label="Author Email Contains",
+ )
+ summary = filters.CharFilter(
+ field_name="summary",
+ lookup_expr="icontains",
+ label="Summary Contains",
+ )
+ content = filters.CharFilter(
+ field_name="content",
+ lookup_expr="icontains",
+ label="Content Contains",
+ )
+
+ class Meta:
+ model = Post
+ fields = ["author", "tags"]
diff --git a/blog/api/permission.py b/blog/api/permission.py
new file mode 100644
index 0000000000..3eab033d21
--- /dev/null
+++ b/blog/api/permission.py
@@ -0,0 +1,9 @@
+from rest_framework import permissions
+
+
+class AuthorModifyOrReadOnly(permissions.IsAuthenticatedOrReadOnly):
+ def has_object_permission(self, request, view, obj):
+ if request.method in permissions.SAFE_METHODS:
+ return True
+
+ return request.user == obj.author
diff --git a/blog/api/serializers.py b/blog/api/serializers.py
new file mode 100644
index 0000000000..d35a939516
--- /dev/null
+++ b/blog/api/serializers.py
@@ -0,0 +1,73 @@
+from rest_framework import serializers
+from blog.models import Post, Tag, Comment
+
+from blango_auth.models import User
+from versatileimagefield.serializers import VersatileImageFieldSerializer
+
+
+class PostSerializer(serializers.ModelSerializer):
+ hero_image = VersatileImageFieldSerializer(
+ sizes=[
+ ("full_size", "url"),
+ ("thumbnail", "thumbnail__100x100"),
+ ("square_crop", "crop__200x200"),
+ ],
+ read_only=True,
+ )
+ class Meta:
+ model = Post
+ #fields = "__all__"
+ readonly = ["modified_at", "created_at"]
+ exclude = ["ppoi"]
+
+
+
+class UserSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = User
+ fields = ["first_name", "last_name", "email"]
+
+
+class CommentSerializer(serializers.ModelSerializer):
+ id = serializers.IntegerField(required=False)
+ creator = UserSerializer(read_only=True)
+
+ class Meta:
+ model = Comment
+ fields = ["id", "creator", "content", "modified_at", "created_at"]
+ readonly = ["modified_at", "created_at"]
+
+class PostDetailSerializer(PostSerializer):
+ comments = CommentSerializer(many=True)
+
+ def update(self, instance, validated_data):
+ comments = validated_data.pop("comments")
+
+ instance = super(PostDetailSerializer, self).update(instance, validated_data)
+
+ for comment_data in comments:
+ if comment_data.get("id"):
+ # comment has an ID so was pre-existing
+ continue
+ comment = Comment(**comment_data)
+ comment.creator = self.context["request"].user
+ comment.content_object = instance
+ comment.save()
+
+ return instance
+
+
+
+
+class TagField(serializers.SlugRelatedField):
+ def to_internal_value(self, data):
+ try:
+ return self.get_queryset().get_or_create(value=data.lower())[0]
+ except (TypeError, ValueError):
+ self.fail(f"Tag value {data} is invalid")
+
+
+class TagSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Tag
+ fields = "__all__"
diff --git a/blog/api/throttling.py b/blog/api/throttling.py
new file mode 100644
index 0000000000..f4eda89760
--- /dev/null
+++ b/blog/api/throttling.py
@@ -0,0 +1,17 @@
+from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
+
+
+class AnonSustainedThrottle(AnonRateThrottle):
+ scope = "anon_sustained"
+
+
+class AnonBurstThrottle(AnonRateThrottle):
+ scope = "anon_burst"
+
+
+class UserSustainedThrottle(UserRateThrottle):
+ scope = "user_sustained"
+
+
+class UserBurstThrottle(UserRateThrottle):
+ scope = "user_burst"
diff --git a/blog/api/urls.py b/blog/api/urls.py
new file mode 100644
index 0000000000..12bc557c89
--- /dev/null
+++ b/blog/api/urls.py
@@ -0,0 +1,75 @@
+from django.urls import path, include
+from rest_framework.urlpatterns import format_suffix_patterns
+from rest_framework.authtoken import views
+from blog.api.views import PostList, PostDetail, UserDetail
+from django.urls import path, include, re_path
+from drf_yasg import openapi
+from drf_yasg.views import get_schema_view
+from rest_framework.routers import DefaultRouter
+from blog.api.views import PostList, PostDetail, UserDetail, TagViewSet
+from blog.api.views import UserDetail, TagViewSet, PostViewSet
+from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
+
+
+import os
+
+
+router = DefaultRouter()
+router.register("tags", TagViewSet)
+router.register("posts", PostViewSet)
+
+
+schema_view = get_schema_view(
+ openapi.Info(
+ title="Blango API",
+ default_version="v1",
+ description="API for Blango Blog",
+ ),
+ url=f"https://{os.environ.get('CODIO_HOSTNAME')}-8000.codio.io/api/v1/",
+ public=True,
+)
+
+
+urlpatterns = [
+ path("posts/", PostList.as_view(), name="api_post_list"),
+ path("posts/", PostDetail.as_view(), name="api_post_detail"),
+ path("users/", UserDetail.as_view(), name="api_user_detail"),
+
+]
+
+urlpatterns = format_suffix_patterns(urlpatterns)
+urlpatterns += [
+ path("auth/", include("rest_framework.urls")),
+ path("token-auth/", views.obtain_auth_token),
+ path("jwt/", TokenObtainPairView.as_view(), name="jwt_obtain_pair"),
+ path("jwt/refresh/", TokenRefreshView.as_view(), name="jwt_refresh"),
+
+ re_path(
+ r"^swagger(?P\.json|\.yaml)$",
+ schema_view.without_ui(cache_timeout=0),
+ name="schema-json",
+ ),
+ path(
+ "swagger/",
+ schema_view.with_ui("swagger", cache_timeout=0),
+ name="schema-swagger-ui",
+ ),
+]
+
+
+urlpatterns += [
+ path("auth/", include("rest_framework.urls")),
+ # ... other patterns omitted
+ path("", include(router.urls)),
+ path(
+ "posts/by-time//",
+ PostViewSet.as_view({"get": "list"}),
+ name="posts-by-time",
+ ),
+
+ path("posts/", PostList.as_view(), name="api_post_list"),
+ path("posts/", PostDetail.as_view(), name="api_post_detail"),
+]
+
+
+
diff --git a/blog/api/views.py b/blog/api/views.py
new file mode 100644
index 0000000000..03db868835
--- /dev/null
+++ b/blog/api/views.py
@@ -0,0 +1,186 @@
+from rest_framework import generics
+
+from blog.api.serializers import PostSerializer, UserSerializer
+from blog.models import Post
+from blog.api.permission import AuthorModifyOrReadOnly
+from blango_auth.models import User
+from rest_framework.authentication import SessionAuthentication
+from rest_framework.permissions import IsAdminUser
+from blog.api.serializers import PostSerializer, UserSerializer, PostDetailSerializer
+from rest_framework import generics, viewsets
+from django.db.models import Q
+from django.utils import timezone
+import django_filters.rest_framework
+
+from blog.api.serializers import (
+ PostSerializer,
+ UserSerializer,
+ PostDetailSerializer,
+ TagSerializer,
+)
+from blog.models import Post, Tag
+
+from django.utils.decorators import method_decorator
+from django.views.decorators.cache import cache_page
+from django.views.decorators.vary import vary_on_headers, vary_on_cookie
+
+from rest_framework.exceptions import PermissionDenied
+from datetime import timedelta
+from django.http import Http404
+from blog.api.filters import PostFilterSet
+
+
+
+
+
+class PostList(generics.ListCreateAPIView):
+ queryset = Post.objects.all()
+ authentication_classes = [SessionAuthentication]
+ permission_classes = [IsAdminUser]
+ serializer_class = PostSerializer
+
+# class PostDetail(generics.RetrieveUpdateDestroyAPIView):
+# queryset = Post.objects.all()
+# permission_classes = [AuthorModifyOrReadOnly]
+# serializer_class = PostSerializer
+class PostDetail(generics.RetrieveUpdateDestroyAPIView):
+ permission_classes = [AuthorModifyOrReadOnly ]
+ queryset = Post.objects.all()
+ serializer_class = PostDetailSerializer
+
+
+class UserDetail(generics.RetrieveAPIView):
+ queryset = User.objects.all()
+ authentication_classes = [SessionAuthentication]
+ serializer_class = UserSerializer
+
+
+class TagViewSet(viewsets.ModelViewSet):
+ queryset = Tag.objects.all()
+ serializer_class = TagSerializer
+ def posts(self, request, pk=None):
+ tag = self.get_object()
+ page = self.paginate_queryset(tag.posts)
+ if page is not None:
+ post_serializer = PostSerializer(
+ page, many=True, context={"request": request}
+ )
+ return self.get_paginated_response(post_serializer.data)
+ post_serializer = PostSerializer(
+ tag.posts, many=True, context={"request": request}
+ )
+ return Response(post_serializer.data)
+
+
+
+
+class PostViewSet(viewsets.ModelViewSet):
+ permission_classes = [SessionAuthentication]
+ filterset_class = PostFilterSet
+ ordering_fields = ["published_at", "author", "title", "slug"]
+ # filter_backends = [django_filters.rest_framework.DjangoFilterBackend]
+ # filterset_fields = ["author", "tags"]
+ queryset = Post.objects.all()
+
+
+ def get_queryset(self):
+ if self.request.user.is_anonymous:
+ # published only
+ queryset = self.queryset.filter(published_at__lte=timezone.now())
+
+ elif not self.request.user.is_staff:
+ # allow all
+ queryset = self.queryset
+ else:
+ queryset = self.queryset
+
+ time_period_name = self.kwargs.get("period_name")
+
+ if not time_period_name:
+ # no further filtering required
+ return queryset
+
+ if time_period_name == "new":
+ return queryset.filter(
+ published_at__gte=timezone.now() - timedelta(hours=1)
+ )
+ elif time_period_name == "today":
+ return queryset.filter(
+ published_at__date=timezone.now().date(),
+ )
+ elif time_period_name == "week":
+ return queryset.filter(published_at__gte=timezone.now() - timedelta(days=7))
+ else:
+ raise Http404(
+ f"Time period {time_period_name} is not valid, should be "
+ f"'new', 'today' or 'week'"
+ )
+
+
+ def get_serializer_class(self):
+ if self.action in ("list", "create"):
+ return PostSerializer
+ return PostDetailSerializer
+
+from rest_framework.decorators import action
+from rest_framework.response import Response
+
+
+class TagViewSet(viewsets.ModelViewSet):
+ queryset = Tag.objects.all()
+ serializer_class = TagSerializer
+
+ @action(methods=["get"], detail=True, name="Posts with the Tag")
+ def posts(self, request, pk=None):
+ tag = self.get_object()
+ post_serializer = PostSerializer(
+ tag.posts, many=True, context={"request": request}
+ )
+ return Response(post_serializer.data)
+
+
+@method_decorator(cache_page(300))
+@method_decorator(vary_on_headers("Authorization"))
+@method_decorator(vary_on_cookie)
+@action(methods=["get"], detail=False, name="Posts by the logged in user")
+def mine(self, request):
+ if request.user.is_anonymous:
+ raise PermissionDenied("You must be logged in to see which Posts are yours")
+ posts = self.get_queryset().filter(author=request.user)
+ serializer = PostSerializer(posts, many=True, context={"request": request})
+ return Response(serializer.data)
+
+
+
+@method_decorator(cache_page(120))
+@method_decorator(vary_on_headers("Authorization", "Cookie"))
+def list(self, *args, **kwargs):
+ return super(PostViewSet, self).list(*args, **kwargs)
+
+@method_decorator(cache_page(300))
+def get(self, *args, **kwargs):
+ return super(UserDetail, self).get(*args, *kwargs)
+
+
+@method_decorator(cache_page(300))
+def list(self, *args, **kwargs):
+ return super(TagViewSet, self).list(*args, **kwargs)
+
+@method_decorator(cache_page(300))
+def retrieve(self, *args, **kwargs):
+ return super(TagViewSet, self).retrieve(*args, **kwargs)
+
+
+def mine(self, request):
+ if request.user.is_anonymous:
+ raise PermissionDenied("You must be logged in to see which Posts are yours")
+ posts = self.get_queryset().filter(author=request.user)
+
+ page = self.paginate_queryset(posts)
+
+ if page is not None:
+ serializer = PostSerializer(page, many=True, context={"request": request})
+ return self.get_paginated_response(serializer.data)
+
+ serializer = PostSerializer(posts, many=True, context={"request": request})
+ return Response(serializer.data)
diff --git a/blog/api_urls.py b/blog/api_urls.py
new file mode 100644
index 0000000000..888fa20846
--- /dev/null
+++ b/blog/api_urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from blog.api_views import post_list, post_detail
+from rest_framework.urlpatterns import format_suffix_patterns
+
+urlpatterns = [
+ path("posts/", post_list, name="api_post_list"),
+ path("posts//", post_detail, name="api_post_detail"),
+]
+
+urlpatterns = format_suffix_patterns(urlpatterns)
\ No newline at end of file
diff --git a/blog/api_views.py b/blog/api_views.py
new file mode 100644
index 0000000000..06f62f754b
--- /dev/null
+++ b/blog/api_views.py
@@ -0,0 +1,44 @@
+from http import HTTPStatus
+
+from django.urls import reverse
+from rest_framework.decorators import api_view
+from rest_framework.response import Response
+
+from blog.api.serializers import PostSerializer
+from blog.models import Post
+
+
+@api_view(["GET", "POST"])
+def post_list(request, format =None):
+ if request.method == "GET":
+ posts = Post.objects.all()
+ return Response({"data": PostSerializer(posts, many=True).data})
+ elif request.method == "POST":
+ serializer = PostSerializer(data=request.data)
+ if serializer.is_valid():
+ post = serializer.save()
+ return Response(
+ status=HTTPStatus.CREATED,
+ headers={"Location": reverse("api_post_detail", args=(post.pk,))},
+ )
+ return Response(serializer.errors, status=HTTPStatus.BAD_REQUEST)
+
+
+@api_view(["GET", "PUT", "DELETE"])
+def post_detail(request, pk, format=None):
+ try:
+ post = Post.objects.get(pk=pk)
+ except Post.DoesNotExist:
+ return Response(status=HTTPStatus.NOT_FOUND)
+
+ if request.method == "GET":
+ return Response(PostSerializer(post).data)
+ elif request.method == "PUT":
+ serializer = PostSerializer(post, data=request.data)
+ if serializer.is_valid():
+ serializer.save()
+ return Response(status=HTTPStatus.NO_CONTENT)
+ return Response(serializer.errors, status=HTTPStatus.BAD_REQUEST)
+ elif request.method == "DELETE":
+ post.delete()
+ return Response(status=HTTPStatus.NO_CONTENT)
diff --git a/blog/apps.py b/blog/apps.py
new file mode 100644
index 0000000000..94788a5eac
--- /dev/null
+++ b/blog/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class BlogConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'blog'
diff --git a/blog/migrations/0001_initial.py b/blog/migrations/0001_initial.py
new file mode 100644
index 0000000000..3e1850049b
--- /dev/null
+++ b/blog/migrations/0001_initial.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.2.5 on 2023-12-22 01:01
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Tag',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('value', models.TextField(max_length=100)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Post',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('modified_at', models.DateTimeField(auto_now=True)),
+ ('published_at', models.DateTimeField(blank=True, null=True)),
+ ('title', models.TextField(max_length=100)),
+ ('slug', models.SlugField()),
+ ('summary', models.TextField(max_length=500)),
+ ('content', models.TextField()),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ ('tags', models.ManyToManyField(related_name='posts', to='blog.Tag')),
+ ],
+ ),
+ ]
diff --git a/blog/migrations/0002_comment.py b/blog/migrations/0002_comment.py
new file mode 100644
index 0000000000..68776ce631
--- /dev/null
+++ b/blog/migrations/0002_comment.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.5 on 2023-12-23 15:21
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('blog', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Comment',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('content', models.TextField()),
+ ('object_id', models.PositiveIntegerField()),
+ ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
+ ('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/blog/migrations/0003_auto_20231223_1542.py b/blog/migrations/0003_auto_20231223_1542.py
new file mode 100644
index 0000000000..7b056999cc
--- /dev/null
+++ b/blog/migrations/0003_auto_20231223_1542.py
@@ -0,0 +1,49 @@
+# Generated by Django 3.2.5 on 2023-12-23 15:42
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0002_comment'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='post',
+ name='author',
+ ),
+ migrations.RemoveField(
+ model_name='post',
+ name='content',
+ ),
+ migrations.RemoveField(
+ model_name='post',
+ name='created_at',
+ ),
+ migrations.RemoveField(
+ model_name='post',
+ name='modified_at',
+ ),
+ migrations.RemoveField(
+ model_name='post',
+ name='published_at',
+ ),
+ migrations.RemoveField(
+ model_name='post',
+ name='slug',
+ ),
+ migrations.RemoveField(
+ model_name='post',
+ name='summary',
+ ),
+ migrations.RemoveField(
+ model_name='post',
+ name='tags',
+ ),
+ migrations.RemoveField(
+ model_name='post',
+ name='title',
+ ),
+ ]
diff --git a/blog/migrations/0004_auto_20231226_1443.py b/blog/migrations/0004_auto_20231226_1443.py
new file mode 100644
index 0000000000..e009b3ec1b
--- /dev/null
+++ b/blog/migrations/0004_auto_20231226_1443.py
@@ -0,0 +1,84 @@
+# Generated by Django 3.2.5 on 2023-12-26 14:43
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('blog', '0003_auto_20231223_1542'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='comment',
+ name='created_at',
+ field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='comment',
+ name='modified_at',
+ field=models.DateTimeField(auto_now=True),
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='author',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='content',
+ field=models.TextField(default='content'),
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='created_at',
+ field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='modified_at',
+ field=models.DateTimeField(auto_now=True),
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='published_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='slug',
+ field=models.SlugField(default='test'),
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='summary',
+ field=models.TextField(default='summary', max_length=500),
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='tags',
+ field=models.ManyToManyField(related_name='posts', to='blog.Tag'),
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='title',
+ field=models.TextField(default='title', max_length=100),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='content',
+ field=models.TextField(default='content'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='object_id',
+ field=models.PositiveIntegerField(default=1),
+ ),
+ ]
diff --git a/blog/migrations/0005_alter_post_published_at.py b/blog/migrations/0005_alter_post_published_at.py
new file mode 100644
index 0000000000..11d7cf0dfa
--- /dev/null
+++ b/blog/migrations/0005_alter_post_published_at.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.23 on 2024-01-01 13:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0004_auto_20231226_1443'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='post',
+ name='published_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ ]
diff --git a/blog/migrations/0005_alter_post_slug.py b/blog/migrations/0005_alter_post_slug.py
new file mode 100644
index 0000000000..90dae7a311
--- /dev/null
+++ b/blog/migrations/0005_alter_post_slug.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.6 on 2024-01-02 09:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0004_auto_20231226_1443'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='post',
+ name='slug',
+ field=models.SlugField(unique=True),
+ ),
+ ]
diff --git a/blog/migrations/0006_auto_20240101_1321.py b/blog/migrations/0006_auto_20240101_1321.py
new file mode 100644
index 0000000000..7376c8344f
--- /dev/null
+++ b/blog/migrations/0006_auto_20240101_1321.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.23 on 2024-01-01 13:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0004_auto_20231226_1443'),
+ # ('blog', '0005_alter_post_published_at'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='comment',
+ name='created_at',
+ field=models.DateTimeField(auto_now_add=True, db_index=True),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='object_id',
+ field=models.PositiveIntegerField(db_index=True, default=1),
+ ),
+ ]
diff --git a/blog/migrations/0007_authorprofile.py b/blog/migrations/0007_authorprofile.py
new file mode 100644
index 0000000000..61699c74d1
--- /dev/null
+++ b/blog/migrations/0007_authorprofile.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.6 on 2024-01-02 02:18
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+# ('blog', '0006_auto_20240101_1321'),
+ ('blog', '0004_auto_20231226_1443'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='AuthorProfile',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('bio', models.TextField()),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/blog/migrations/0008_merge_20240102_0948.py b/blog/migrations/0008_merge_20240102_0948.py
new file mode 100644
index 0000000000..f094093e47
--- /dev/null
+++ b/blog/migrations/0008_merge_20240102_0948.py
@@ -0,0 +1,16 @@
+# Generated by Django 3.2.6 on 2024-01-02 09:48
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0005_alter_post_published_at'),
+ ('blog', '0005_alter_post_slug'),
+ ('blog', '0006_auto_20240101_1321'),
+ ('blog', '0007_authorprofile'),
+ ]
+
+ operations = [
+ ]
diff --git a/blog/migrations/0009_alter_post_slug.py b/blog/migrations/0009_alter_post_slug.py
new file mode 100644
index 0000000000..050c2b8bed
--- /dev/null
+++ b/blog/migrations/0009_alter_post_slug.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.6 on 2024-01-05 01:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0008_merge_20240102_0948'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='post',
+ name='slug',
+ field=models.SlugField(default='test', unique=True),
+ ),
+ ]
diff --git a/blog/migrations/0010_alter_tag_value.py b/blog/migrations/0010_alter_tag_value.py
new file mode 100644
index 0000000000..975552137e
--- /dev/null
+++ b/blog/migrations/0010_alter_tag_value.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.6 on 2024-01-06 09:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0009_alter_post_slug'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='tag',
+ name='value',
+ field=models.TextField(max_length=100, unique=True),
+ ),
+ ]
diff --git a/blog/migrations/0011_auto_20240115_1411.py b/blog/migrations/0011_auto_20240115_1411.py
new file mode 100644
index 0000000000..961283f599
--- /dev/null
+++ b/blog/migrations/0011_auto_20240115_1411.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.2.6 on 2024-01-15 14:11
+
+from django.db import migrations
+import versatileimagefield.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0010_alter_tag_value'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='tag',
+ options={'ordering': ['value']},
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='hero_image',
+ field=versatileimagefield.fields.VersatileImageField(blank=True, null=True, upload_to='hero_images'),
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='ppoi',
+ field=versatileimagefield.fields.PPOIField(blank=True, default='0.5x0.5', editable=False, max_length=20, null=True),
+ ),
+ ]
diff --git a/blog/migrations/__init__.py b/blog/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/blog/migrations/__pycache__/0001_initial.cpython-36.pyc b/blog/migrations/__pycache__/0001_initial.cpython-36.pyc
new file mode 100644
index 0000000000..61acf3be88
Binary files /dev/null and b/blog/migrations/__pycache__/0001_initial.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0002_comment.cpython-36.pyc b/blog/migrations/__pycache__/0002_comment.cpython-36.pyc
new file mode 100644
index 0000000000..05e6c9d8e6
Binary files /dev/null and b/blog/migrations/__pycache__/0002_comment.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0003_auto_20231223_1542.cpython-36.pyc b/blog/migrations/__pycache__/0003_auto_20231223_1542.cpython-36.pyc
new file mode 100644
index 0000000000..1869f1604c
Binary files /dev/null and b/blog/migrations/__pycache__/0003_auto_20231223_1542.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0004_auto_20231223_1549.cpython-36.pyc b/blog/migrations/__pycache__/0004_auto_20231223_1549.cpython-36.pyc
new file mode 100644
index 0000000000..94bab465c5
Binary files /dev/null and b/blog/migrations/__pycache__/0004_auto_20231223_1549.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0004_auto_20231226_1443.cpython-36.pyc b/blog/migrations/__pycache__/0004_auto_20231226_1443.cpython-36.pyc
new file mode 100644
index 0000000000..503c8dca7e
Binary files /dev/null and b/blog/migrations/__pycache__/0004_auto_20231226_1443.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0005_alter_post_published_at.cpython-36.pyc b/blog/migrations/__pycache__/0005_alter_post_published_at.cpython-36.pyc
new file mode 100644
index 0000000000..871e764e53
Binary files /dev/null and b/blog/migrations/__pycache__/0005_alter_post_published_at.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0005_alter_post_slug.cpython-36.pyc b/blog/migrations/__pycache__/0005_alter_post_slug.cpython-36.pyc
new file mode 100644
index 0000000000..af1223f86f
Binary files /dev/null and b/blog/migrations/__pycache__/0005_alter_post_slug.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0006_auto_20240101_1321.cpython-36.pyc b/blog/migrations/__pycache__/0006_auto_20240101_1321.cpython-36.pyc
new file mode 100644
index 0000000000..88aae2a4e6
Binary files /dev/null and b/blog/migrations/__pycache__/0006_auto_20240101_1321.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0007_authorprofile.cpython-36.pyc b/blog/migrations/__pycache__/0007_authorprofile.cpython-36.pyc
new file mode 100644
index 0000000000..d60d890721
Binary files /dev/null and b/blog/migrations/__pycache__/0007_authorprofile.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0008_merge_20240102_0948.cpython-36.pyc b/blog/migrations/__pycache__/0008_merge_20240102_0948.cpython-36.pyc
new file mode 100644
index 0000000000..29bf485665
Binary files /dev/null and b/blog/migrations/__pycache__/0008_merge_20240102_0948.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0009_alter_post_slug.cpython-36.pyc b/blog/migrations/__pycache__/0009_alter_post_slug.cpython-36.pyc
new file mode 100644
index 0000000000..ef40dc905b
Binary files /dev/null and b/blog/migrations/__pycache__/0009_alter_post_slug.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0010_alter_tag_value.cpython-36.pyc b/blog/migrations/__pycache__/0010_alter_tag_value.cpython-36.pyc
new file mode 100644
index 0000000000..dee540ec3b
Binary files /dev/null and b/blog/migrations/__pycache__/0010_alter_tag_value.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/0011_auto_20240115_1411.cpython-36.pyc b/blog/migrations/__pycache__/0011_auto_20240115_1411.cpython-36.pyc
new file mode 100644
index 0000000000..c03d698b58
Binary files /dev/null and b/blog/migrations/__pycache__/0011_auto_20240115_1411.cpython-36.pyc differ
diff --git a/blog/migrations/__pycache__/__init__.cpython-36.pyc b/blog/migrations/__pycache__/__init__.cpython-36.pyc
new file mode 100644
index 0000000000..530f9cb814
Binary files /dev/null and b/blog/migrations/__pycache__/__init__.cpython-36.pyc differ
diff --git a/blog/models.py b/blog/models.py
new file mode 100644
index 0000000000..8cab32ab01
--- /dev/null
+++ b/blog/models.py
@@ -0,0 +1,59 @@
+from django.db import models
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes.fields import GenericForeignKey
+from versatileimagefield.fields import VersatileImageField, PPOIField
+
+# Create your models here.
+class Tag(models.Model):
+ value = models.TextField(max_length=100, unique=True)
+ class Meta:
+ ordering = ["value"]
+
+ def __str__(self):
+ return self.value
+
+
+class Comment(models.Model):
+ creator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+ content = models.TextField(default="content")
+ content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+ object_id = models.PositiveIntegerField(default=1, db_index=True)
+ content_object = GenericForeignKey("content_type", "object_id")
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
+ modified_at = models.DateTimeField(auto_now=True)
+
+
+class Post(models.Model):
+ author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, default =1)
+ created_at = models.DateTimeField(auto_now_add=True)
+ hero_image = VersatileImageField(
+ upload_to="hero_images", ppoi_field="ppoi", null=True, blank=True
+ )
+ ppoi = PPOIField(null=True, blank=True)
+ modified_at = models.DateTimeField(auto_now=True)
+ published_at = models.DateTimeField(blank=True, null=True)
+ title = models.TextField(max_length=100, default="title")
+ slug = models.SlugField(default = "test", unique=True)
+ summary = models.TextField(max_length=500, default="summary")
+ content = models.TextField(default="content")
+ tags = models.ManyToManyField(Tag, related_name="posts")
+ comments = GenericRelation(Comment)
+ published_at = models.DateTimeField(blank=True, null=True, db_index=True)
+
+
+ def __str__(self):
+ return self.title
+
+
+
+class AuthorProfile(models.Model):
+ user = models.OneToOneField(
+ settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile"
+ )
+ bio = models.TextField()
+
+ def __str__(self):
+ return f"{self.__class__.__name__} object for {self.user}"
+
diff --git a/blog/static/blog/blog.js b/blog/static/blog/blog.js
new file mode 100644
index 0000000000..c21366bf49
--- /dev/null
+++ b/blog/static/blog/blog.js
@@ -0,0 +1,376 @@
+// react based
+
+['/api/v1/posts/', '/', '/abadurl/'].forEach(url => {
+ fetch(url).then(response => {
+ if (response.status !== 200) {
+ throw new Error('Invalid status from server: ' + response.statusText)
+ }
+
+ return response.json()
+ }).then(data => {
+ // do something with data, for example
+ console.log(data)
+ }).catch(e => {
+ console.error(e)
+ })
+})
+componentDidMount () {
+ fetch(this.props.url).then(response => {
+ if (response.status !== 200) {
+ throw new Error('Invalid status from server: ' + response.statusText)
+ }
+
+ return response.json()
+ }).then(data => {
+ this.setState({
+ dataLoaded: true,
+ data: data
+ })
+ }).catch(e => {
+ console.error(e)
+ this.setState({
+ dataLoaded: true,
+ data: {
+ results: []
+ }
+ })
+ })
+ }
+
+
+class PostRow extends React.Component {
+ render () {
+ const post = this.props.post
+
+ let thumbnail
+
+ if (post.hero_image.thumbnail) {
+ thumbnail =
+ } else {
+ thumbnail = '-'
+ }
+
+ return
+ {post.title} |
+
+ {thumbnail}
+ |
+ {post.tags.join(', ')} |
+ {post.slug} |
+ {post.summary} |
+ View |
+
+ }
+}
+
+class PostTable extends React.Component {
+ state = {
+ dataLoaded: true,
+ data: {
+ results: [
+ {
+ id: 15,
+ tags: [
+ 'django', 'react'
+ ],
+ 'hero_image': {
+ 'thumbnail': '/media/__sized__/hero_images/data_insights-thumbnail-100x100.png',
+ 'full_size': '/media/hero_images/data_insights.png'
+ },
+ title: 'Test Post',
+ slug: 'test-post',
+ summary: 'A test post, created for Django/React.'
+ }
+ ]
+ }
+ }
+
+ render () {
+ let rows
+ if (this.state.dataLoaded) {
+ if (this.state.data.results.length) {
+ rows = this.state.data.results.map(post => )
+ } else {
+ rows =
+ No results found. |
+
+ }
+ } else {
+ rows =
+ Loading… |
+
+ }
+
+ return
+
+
+ Title |
+ Image |
+ Tags |
+ Slug |
+ Summary |
+ Link |
+
+
+
+ {rows}
+
+
+ }
+}
+
+const domContainer = document.getElementById('react_root')
+
+ReactDOM.render(
+ React.createElement(
+ PostTable,
+ {url: postListUrl}
+ ),
+ domContainer
+)
+
+// ReactDOM.render(
+// React.createElement(PostTable),
+// domContainer
+// )
+
+
+
+// class ClickButton extends React.Component {
+// state = {
+// wasClicked: false
+// }
+
+// handleClick () {
+// this.setState(
+// {wasClicked: true}
+// )
+// }
+
+// render () {
+// let buttonText
+
+// if (this.state.wasClicked)
+// buttonText = 'Clicked!'
+// else
+// buttonText = 'Click Me'
+
+// return React.createElement(
+// 'button',
+// {
+// className: 'btn btn-primary mt-2',
+// onClick: () => {
+// this.handleClick()
+// }
+// },
+// buttonText
+// )
+// }
+// }
+
+// const domContainer = document.getElementById('react_root')
+// ReactDOM.render(
+// React.createElement(ClickButton),
+// domContainer
+// )
+
+
+// using jsx
+
+ // render () {
+ // let buttonText
+
+ // if (this.state.wasClicked)
+ // buttonText = 'Clicked!'
+ // else
+ // buttonText = 'Click Me'
+
+ // return
+ // }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+// alert('Hello, world!')
+// const theNumber = 1
+// //let yourName = 'Thiru'
+
+// if (theNumber === 2) {
+// let yourName = 'valluvan'
+// alert(yourName)
+// }
+
+// alert(yourName)
+
+// console.time('myTimer')
+// console.count('counter1')
+// console.log('A normal log message')
+// console.warn('Warning: something bad might happen')
+// console.error('Something bad did happen!')
+// console.count('counter1')
+// console.log('All the things above took this long to happen:')
+// console.timeEnd('myTimer')
+
+// function sayHello(yourName) {
+// if (yourName === undefined) {
+// console.log('Hello, no name')
+// } else {
+// console.log('Hello, ' + yourName)
+// }
+// }
+
+// const yourName = 'Thiru' // Put your name here
+
+// console.log('Before setTimeout')
+
+// setTimeout(() => {
+// sayHello(yourName)
+// }, 2000
+// )
+
+// console.log('After setTimeout')
+
+// for(let i = 0; i < 10; i += 1) {
+// console.log('for loop i: ' + i)
+// }
+
+// let j = 0
+// while(j < 10) {
+// console.log('while loop j: ' + j)
+// j += 1
+// }
+
+// let k = 10
+
+// do {
+// console.log('do while k: ' + k)
+// } while(k < 10)
+
+// const numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+
+// numbers.forEach((value => {
+// console.log('For each value ' + value)
+// }))
+
+// const doubled = numbers.map(value => value * 2)
+
+// console.log('Here are the doubled numbers')
+
+// console.log(doubled)
+
+////class related
+
+// class Greeter {
+// constructor (name) {
+// this.name = name
+// }
+
+// getGreeting () {
+// if (this.name === undefined) {
+// return 'Hello, no name'
+// }
+
+// return 'Hello, ' + this.name
+// }
+
+// showGreeting (greetingMessage) {
+// console.log(greetingMessage)
+// }
+
+// greet () {
+// this.showGreeting(this.getGreeting())
+// }
+// }
+
+// const g = new Greeter('Thiru') // Put your name here if you like
+// g.greet()
+
+
+// class DelayedGreeter extends Greeter {
+// delay = 2000
+
+// constructor (name, delay) {
+// super(name)
+// if (delay !== undefined) {
+// this.delay = delay
+// }
+// }
+
+// greet () {
+// setTimeout(
+// () => {
+// this.showGreeting(this.getGreeting())
+// }, this.delay
+// )
+// }
+// }
+
+// const dg2 = new DelayedGreeter('Thiru 2 Seconds')
+// dg2.greet()
+
+// const dg1 = new DelayedGreeter('Thiru 1 Second', 1000)
+// dg1.greet()
+
+
+// function resolvedCallback(data) {
+// console.log('Resolved with data ' + data)
+// }
+
+// function rejectedCallback(message) {
+// console.log('Rejected with message ' + message)
+// }
+
+// const lazyAdd = function (a, b) {
+// const doAdd = (resolve, reject) => {
+// if (typeof a !== "number" || typeof b !== "number") {
+// reject("a and b must both be numbers")
+// } else {
+// const sum = a + b
+// resolve(sum)
+// }
+// }
+
+// return new Promise(doAdd)
+// }
+
+// const p = lazyAdd(3, 4)
+// p.then(resolvedCallback, rejectedCallback)
+
+// lazyAdd("nan", "alsonan").then(resolvedCallback, rejectedCallback)
+
+
diff --git a/blog/templates/base.html b/blog/templates/base.html
new file mode 100644
index 0000000000..f37ee2e448
--- /dev/null
+++ b/blog/templates/base.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+ {% block title %}Welcome to Blango{% endblock %}
+
+
+
+
+
+
+ {% block content %}
+
+ {% endblock %}
+
+
+
+
+
diff --git a/blog/templates/blog/index.html b/blog/templates/blog/index.html
new file mode 100644
index 0000000000..7da2955fd4
--- /dev/null
+++ b/blog/templates/blog/index.html
@@ -0,0 +1,30 @@
+{% extends "base.html" %}
+
+
+
+{% block content %}
+{% load blog_extras %}
+ Blog Posts
+ {% for post in posts %}
+
+
+
{{ post.title }}
+
By {{ post.author|author_details:request.user }} on {{ post.published_at|date:"M, d Y" }}
+
{{ post.summary }}
+
+ ({{ post.content|wordcount }} words)
+ Read More
+
+ {% if post.hero_image %}
+
+ {% endif %}
+
+
+
+ {% endfor %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/blog/templates/blog/post-table.html b/blog/templates/blog/post-table.html
new file mode 100644
index 0000000000..49e1a3c74d
--- /dev/null
+++ b/blog/templates/blog/post-table.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% load static %}
+{% block content %}
+{% block title %}Post Table{% endblock %}
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/blog/templatetags/__init__.py b/blog/templatetags/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/blog/templatetags/__pycache__/__init__.cpython-36.pyc b/blog/templatetags/__pycache__/__init__.cpython-36.pyc
new file mode 100644
index 0000000000..ef4778ea80
Binary files /dev/null and b/blog/templatetags/__pycache__/__init__.cpython-36.pyc differ
diff --git a/blog/templatetags/__pycache__/blog_extras.cpython-36.pyc b/blog/templatetags/__pycache__/blog_extras.cpython-36.pyc
new file mode 100644
index 0000000000..4bee88e944
Binary files /dev/null and b/blog/templatetags/__pycache__/blog_extras.cpython-36.pyc differ
diff --git a/blog/templatetags/blog_extras.py b/blog/templatetags/blog_extras.py
new file mode 100644
index 0000000000..068c458bbe
--- /dev/null
+++ b/blog/templatetags/blog_extras.py
@@ -0,0 +1,36 @@
+from django import template
+register = template.Library()
+from django.utils.html import format_html
+
+from django.contrib.auth import get_user_model
+user_model = get_user_model()
+import logging
+
+logger = logging.getLogger(__name__)
+
+@register.filter
+def author_details(author, current_user):
+ # logger.debug("Loaded %d recent posts for post %d", len(author), author.id)
+
+ if not isinstance(author, user_model):
+ # return empty string as safe default
+ return ""
+
+ if author == current_user:
+ return format_html("me")
+
+ if author.first_name and author.last_name:
+ name = f"{author.first_name} {author.last_name}"
+ else:
+ name = f"{author.username}"
+
+ if author.email:
+ prefix = format_html('', author.email)
+ suffix = format_html("")
+ else:
+ prefix = ""
+ suffix = ""
+
+ return format_html('{}{}{}', prefix, name, suffix)
+
+
diff --git a/blog/test_post_api.py b/blog/test_post_api.py
new file mode 100644
index 0000000000..79124fa215
--- /dev/null
+++ b/blog/test_post_api.py
@@ -0,0 +1,105 @@
+from datetime import datetime
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.utils import timezone
+from pytz import UTC
+from rest_framework.authtoken.models import Token
+from rest_framework.test import APIClient
+
+from blog.models import Post
+
+
+class PostApiTestCase(TestCase):
+
+ def setUp(self):
+ self.u1 = get_user_model().objects.create_user(
+ email="test@example.com", password="password"
+ )
+
+ self.u2 = get_user_model().objects.create_user(
+ email="test2@example.com", password="password2"
+ )
+
+ posts = [
+ Post.objects.create(
+ author=self.u1,
+ published_at=timezone.now(),
+ title="Post 1 Title",
+ slug="post-1-slug",
+ summary="Post 1 Summary",
+ content="Post 1 Content",
+ ),
+ Post.objects.create(
+ author=self.u2,
+ published_at=timezone.now(),
+ title="Post 2 Title",
+ slug="post-2-slug",
+ summary="Post 2 Summary",
+ content="Post 2 Content",
+ ),
+ ]
+
+ # let us look up the post info by ID
+ self.post_lookup = {p.id: p for p in posts}
+
+ # override test client
+ self.client = APIClient()
+ token = Token.objects.create(user=self.u1)
+ self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key)
+
+ def test_post_list(self):
+ resp = self.client.get("/api/v1/posts/")
+ data = resp.json()["results"]
+ self.assertEqual(len(data), 1)
+
+ for post_dict in data:
+ print(post_dict)
+ post_obj = self.post_lookup[post_dict["id"]]
+ self.assertEqual(post_obj.title, post_dict["title"])
+ self.assertEqual(post_obj.slug, post_dict["slug"])
+ self.assertEqual(post_obj.summary, post_dict["summary"])
+ self.assertEqual(post_obj.content, post_dict["content"])
+ self.assertTrue(
+ post_dict["author"].endswith(f"/api/v1/users/{post_obj.author.email}")
+ )
+ self.assertEqual(
+ post_obj.published_at,
+ datetime.strptime(
+ post_dict["published_at"], "%Y-%m-%dT%H:%M:%S.%fZ"
+ ).replace(tzinfo=UTC),
+ )
+
+ def test_unauthenticated_post_create(self):
+ # unset credentials so we are an anonymous user
+ self.client.credentials()
+ post_dict = {
+ "title": "Test Post",
+ "slug": "test-post-3",
+ "summary": "Test Summary",
+ "content": "Test Content",
+ "author": "http://testserver/api/v1/users/test@example.com",
+ "published_at": "2021-01-10T09:00:00Z",
+ }
+ resp = self.client.post("/api/v1/posts/", post_dict)
+ self.assertEqual(resp.status_code, 401)
+ self.assertEqual(Post.objects.all().count(), 1)
+
+ def test_post_create(self):
+ post_dict = {
+ "title": "Test Post",
+ "slug": "test-post-3",
+ "summary": "Test Summary",
+ "content": "Test Content",
+ "author": "http://testserver/api/v1/users/test@example.com",
+ "published_at": "2021-01-10T09:00:00Z",
+ }
+ resp = self.client.post("/api/v1/posts/", post_dict)
+ post_id = resp.json()["id"]
+ post = Post.objects.get(pk=post_id)
+ self.assertEqual(post.title, post_dict["title"])
+ self.assertEqual(post.slug, post_dict["slug"])
+ self.assertEqual(post.summary, post_dict["summary"])
+ self.assertEqual(post.content, post_dict["content"])
+ self.assertEqual(post.author, self.u1)
+ self.assertEqual(post.published_at, datetime(2021, 1, 10, 9, 0, 0, tzinfo=UTC))
diff --git a/blog/test_tag_api.py b/blog/test_tag_api.py
new file mode 100644
index 0000000000..f4f6f3f0f3
--- /dev/null
+++ b/blog/test_tag_api.py
@@ -0,0 +1,46 @@
+from django.test import LiveServerTestCase
+from requests.auth import HTTPBasicAuth
+from rest_framework.test import RequestsClient
+
+from django.contrib.auth import get_user_model
+from blog.models import Tag
+
+
+class TagApiTestCase(LiveServerTestCase):
+ def setUp(self):
+ get_user_model().objects.create_user(
+ email="testuser@example.com", password="password"
+ )
+
+ self.tag_values = {"tag1", "tag2", "tag3", "tag4"}
+ for t in self.tag_values:
+ Tag.objects.create(value=t)
+ self.client = RequestsClient()
+
+ def test_tag_list(self):
+ resp = self.client.get(self.live_server_url + "/api/v1/tags/")
+ self.assertEqual(resp.status_code, 200)
+ data = resp.json()["results"]
+ self.assertEqual(len(data), 4)
+ self.assertEqual(self.tag_values, {t["value"] for t in data})
+
+ def test_tag_create_basic_auth(self):
+ self.client.auth = HTTPBasicAuth("testuser@example.com", "password")
+ resp = self.client.post(
+ self.live_server_url + "/api/v1/tags/", {"value": "tag5"}
+ )
+ self.assertEqual(resp.status_code, 201)
+ self.assertEqual(Tag.objects.all().count(), 5)
+
+ def test_tag_create_token_auth(self):
+ token_resp = self.client.post(
+ self.live_server_url + "/api/v1/token-auth/",
+ {"username": "testuser@example.com", "password": "password"},
+ )
+ self.client.headers["Authorization"] = "Token " + token_resp.json()["token"]
+
+ resp = self.client.post(
+ self.live_server_url + "/api/v1/tags/", {"value": "tag5"}
+ )
+ self.assertEqual(resp.status_code, 201)
+ self.assertEqual(Tag.objects.all().count(), 5)
diff --git a/blog/tests.py b/blog/tests.py
new file mode 100644
index 0000000000..7ce503c2dd
--- /dev/null
+++ b/blog/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/blog/urls.py b/blog/urls.py
new file mode 100644
index 0000000000..4a842e5787
--- /dev/null
+++ b/blog/urls.py
@@ -0,0 +1,7 @@
+# # other imports
+# import blog.views
+
+# urlpatterns = [
+# # other patterns
+# path("", blog.views.index)
+# ]
diff --git a/blog/views.py b/blog/views.py
new file mode 100644
index 0000000000..a3cf2163a6
--- /dev/null
+++ b/blog/views.py
@@ -0,0 +1,55 @@
+from django.shortcuts import render
+# from .models import Post
+from django.utils import timezone
+from blog.models import Post
+
+import logging
+logger = logging.getLogger(__name__)
+from blog.models import Post
+from django.views.decorators.cache import cache_page
+from django.views.decorators.vary import vary_on_headers
+from django.utils import timezone
+from django.urls import reverse
+
+# Create your views here.
+
+# def index(request):
+# return render(request, "blog/index.html")
+# @cache_page(300)
+# @vary_on_headers("Cookie")
+# def index(request):
+# from django.http import HttpResponse
+# return HttpResponse(str(request.user).encode("ascii"))
+# posts = Post.objects.filter(published_at__lte=timezone.now())
+# logger.debug("Got %d posts", len(posts))
+# return render(request, "blog/index.html", {"posts": posts})
+
+
+def index(request):
+ posts = Post.objects.filter(published_at__lte=timezone.now()).select_related("author")
+
+ # posts = (
+ # Post.objects.filter(published_at__lte=timezone.now())
+ # .select_related("author")
+ # .defer("created_at", "modified_at")
+# )
+
+
+
+
+ # logger.debug("Got %d posts", (posts))
+ return render(request, "blog/index.html", {"posts": posts})
+
+
+def get_ip(request):
+ from django.http import HttpResponse
+ return HttpResponse(request.META['REMOTE_ADDR'])
+
+# def post_table(request):
+# return render(request, "blog/post-table.html")
+
+
+def post_table(request):
+ return render(
+ request, "blog/post-table.html", {"post_list_url": reverse("post-list")}
+ )
diff --git a/data.json b/data.json
new file mode 100644
index 0000000000..6fcbb977e4
--- /dev/null
+++ b/data.json
@@ -0,0 +1 @@
+[{"model": "blog.comment", "pk": 1, "fields": {"creator": 1, "content": "What a great post!", "content_type": 8, "object_id": 1, "created_at": "2023-12-26T14:43:20.297Z", "modified_at": "2023-12-26T14:43:20.312Z"}}, {"model": "blog.comment", "pk": 2, "fields": {"creator": 1, "content": "I like myself!", "content_type": 4, "object_id": 1, "created_at": "2023-12-26T14:43:20.297Z", "modified_at": "2023-12-26T14:43:20.312Z"}}, {"model": "blog.comment", "pk": 3, "fields": {"creator": 1, "content": "Thank you for reading my post!", "content_type": 8, "object_id": 1, "created_at": "2024-01-01T13:47:03.001Z", "modified_at": "2024-01-01T13:47:03.001Z"}}, {"model": "blog.comment", "pk": 4, "fields": {"creator": 2, "content": "Thank you for reading my post!", "content_type": 8, "object_id": 2, "created_at": "2024-01-01T13:47:03.001Z", "modified_at": "2024-01-01T13:47:03.001Z"}}, {"model": "blog.tag", "pk": 1, "fields": {"value": "django"}}, {"model": "blog.tag", "pk": 2, "fields": {"value": "example"}}, {"model": "blog.post", "pk": 1, "fields": {"author": 1, "created_at": "2023-12-26T14:47:03.829Z", "modified_at": "2023-12-26T14:47:03.829Z", "title": "title", "slug": "test", "summary": "summary", "content": "content", "published_at": null, "tags": [1, 2]}}, {"model": "blog.post", "pk": 2, "fields": {"author": 2, "created_at": "2023-12-26T14:47:52.578Z", "modified_at": "2023-12-26T14:47:52.578Z", "title": "Thiru", "slug": "Thiru", "summary": "If you don’t already have some test Blog data added, you should log to the Django admin and add a couple of posts, and another user as an extra author. This will come in handy later.", "content": "Provided you’ve done this and have some test data, when you start the Django dev server and navigate to the main page you should see something like this:", "published_at": null, "tags": [1, 2]}}, {"model": "blog.post", "pk": 3, "fields": {"author": 2, "created_at": "2024-01-01T13:48:31.575Z", "modified_at": "2024-01-01T13:48:31.575Z", "title": "title", "slug": "test", "summary": "summary", "content": "content", "published_at": "2024-01-01T13:48:20Z", "tags": [1, 2]}}, {"model": "auth.user", "pk": 1, "fields": {"password": "pbkdf2_sha256$260000$Vb3Nzqn4v5Fh1KC2VbVR6J$J/F+J8DRBt0BgTmbdH8Ah0sVxtny3E0uXRhPHgPjcy8=", "last_login": null, "is_superuser": true, "username": "codio", "first_name": "", "last_name": "", "email": "", "is_staff": true, "is_active": true, "date_joined": "2023-12-22T00:57:16.353Z", "groups": [], "user_permissions": []}}, {"model": "auth.user", "pk": 2, "fields": {"password": "argon2$argon2id$v=19$m=102400,t=2,p=8$OUhTUkNXc0tVZ3Y2bFhlWGJmUm1xMg$UUWqV9jKQG5J0/a41CEIAg", "last_login": "2024-01-01T13:47:51.013Z", "is_superuser": true, "username": "thiru", "first_name": "Thiruvalluvan", "last_name": "Murugesan", "email": "m.thiruvalluvar@gmail.com", "is_staff": true, "is_active": true, "date_joined": "2023-12-22T00:57:50Z", "groups": [], "user_permissions": []}}]
\ No newline at end of file
diff --git a/db.sqlite3 b/db.sqlite3
new file mode 100644
index 0000000000..a3aa0361fd
Binary files /dev/null and b/db.sqlite3 differ
diff --git a/manage.py b/manage.py
index c66b327f71..8fbae3230d 100644
--- a/manage.py
+++ b/manage.py
@@ -7,6 +7,8 @@
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'blango.settings')
+ os.environ.setdefault("DJANGO_CONFIGURATION", "Dev")
+
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
diff --git a/media/__sized__/hero_images/data_insights-thumbnail-100x100.png b/media/__sized__/hero_images/data_insights-thumbnail-100x100.png
new file mode 100644
index 0000000000..e39bb9f151
Binary files /dev/null and b/media/__sized__/hero_images/data_insights-thumbnail-100x100.png differ
diff --git a/media/__sized__/hero_images/data_insights-thumbnail-300x300.png b/media/__sized__/hero_images/data_insights-thumbnail-300x300.png
new file mode 100644
index 0000000000..cd25311326
Binary files /dev/null and b/media/__sized__/hero_images/data_insights-thumbnail-300x300.png differ
diff --git a/media/hero_images/data_insights.png b/media/hero_images/data_insights.png
new file mode 100644
index 0000000000..8056834d73
Binary files /dev/null and b/media/hero_images/data_insights.png differ
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000..93cec39d90
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,55 @@
+argon2-cffi==20.1.0
+asgiref==3.4.1
+asn1crypto==0.24.0
+certifi==2018.1.18
+cffi==1.14.6
+chardet==3.0.4
+confusable-homoglyphs==3.2.0
+crispy-bootstrap5==0.4
+cryptography==3.4.8
+decorator==4.1.2
+defusedxml==0.7.1
+dj-database-url==0.5.0
+Django==3.2.6
+django-allauth==0.45.0
+django-configurations==2.2
+django-crispy-forms==1.12.0
+django-debug-toolbar==3.2.2
+django-registration==3.2
+djangorestframework==3.12.4
+drf-yasg==1.21.7
+idna==2.6
+inflection==0.5.1
+ipython==5.5.0
+ipython_genutils==0.2.0
+keyring==10.6.0
+keyrings.alt==3.0
+netifaces==0.10.4
+oauthlib==3.1.1
+packaging==21.3
+pexpect==4.2.1
+pickleshare==0.7.4
+prompt-toolkit==1.0.15
+pycparser==2.20
+pycrypto==2.6.1
+Pygments==2.2.0
+PyGObject==3.26.1
+PyJWT==2.1.0
+pyparsing==3.1.1
+python-apt==1.6.5+ubuntu0.3
+python3-openid==3.2.0
+pytz==2021.1
+pyxdg==0.25
+PyYAML==6.0.1
+requests==2.18.4
+requests-oauthlib==1.3.0
+SecretStorage==2.3.1
+simplegeneric==0.8.1
+six==1.16.0
+sqlparse==0.4.1
+ssh-import-id==5.7
+traitlets==4.3.2
+typing-extensions==3.10.0.1
+uritemplate==4.1.1
+urllib3==1.22
+wcwidth==0.1.7