initial commit

This commit is contained in:
Trent Palmer 2021-02-24 20:13:54 -08:00
commit dfeca6a325
50 changed files with 1467 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
db.sqlite3

0
accounts/__init__.py Normal file
View File

3
accounts/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
accounts/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = 'accounts'

58
accounts/enable_totp.py Normal file
View File

@ -0,0 +1,58 @@
from django.shortcuts import redirect, render
import qrcode.image.svg
from .forms import EnableTotpForm
from django.contrib import messages
from .models import Account
from io import BytesIO
import pyotp
import qrcode
def disable_totp(request):
if not request.user.is_authenticated:
return redirect('audio:home')
if not request.user.account.use_totp:
return redirect('audio:home')
if request.method == "POST":
account = Account.objects.get(user=request.user)
account.use_totp = False
account.totp_key = None
account.save()
messages.success(request, 'Thanks for disabling 2fa!', extra_tags="mb-0")
return(redirect('accounts:edit_profile'))
return render(request, 'confirmation.html', {})
def enable_totp(request):
if not request.user.is_authenticated:
return redirect('audio:home')
qr = get_totp_qr(request.user)
if request.method == "POST":
form = EnableTotpForm(request.POST, instance=request.user.account)
if form.is_valid():
totp_code = form.cleaned_data['totp_code']
if pyotp.TOTP(request.user.account.totp_key).verify(int(totp_code), valid_window=5):
account = Account.objects.get(user=request.user)
account.use_totp = True
account.save()
messages.success(request, 'Thanks for enabling 2fa!', extra_tags="mb-0")
return(redirect('accounts:edit_profile'))
else:
messages.error(request, 'Wrong Code, try again?', extra_tags="mb-0")
else:
form = EnableTotpForm(instance=request.user.account)
return render(request, 'accounts/totp_form.html', {'form': form, 'qr': qr})
def get_totp_qr(user):
if user.account.totp_key is None:
account = Account.objects.get(user=user)
account.totp_key = pyotp.random_base32()
account.save()
user.account.totp_key = account.totp_key
totp_uri = pyotp.totp.TOTP(user.account.totp_key).provisioning_uri(name='audio', issuer_name='trentpalmer.org')
img = qrcode.make(totp_uri, image_factory=qrcode.image.svg.SvgPathImage)
f = BytesIO()
img.save(f)
return(f.getvalue().decode('utf-8'))

59
accounts/forms.py Normal file
View File

@ -0,0 +1,59 @@
from django.contrib.auth.forms import ValidationError, UsernameField # , UserCreationForm
from django.contrib.auth.models import User
from django import forms
from .models import Account
class EnableTotpForm(forms.ModelForm):
totp_code = forms.CharField(max_length=6)
class Meta:
model = Account
fields = ("totp_code", )
class EditProfileForm(forms.Form):
email = forms.EmailField(
required=True,
label='Email',
max_length=254,
widget=forms.EmailInput(attrs={'autocomplete': 'email'})
)
first_name = UsernameField(required=False)
last_name = UsernameField(required=False)
password = forms.CharField(
label="confirm password",
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
def __init__(self, user, *args, **kwargs):
self.user = user
super(EditProfileForm, self).__init__(*args, **kwargs)
def clean(self):
email = self.cleaned_data.get('email')
first_name = self.cleaned_data.get('first_name')
last_name = self.cleaned_data.get('last_name')
password = self.cleaned_data["password"]
if not self.user.check_password(password):
raise ValidationError("password is incorrect.")
if email != self.user.email:
if User.objects.filter(email=email).exists():
raise ValidationError("An account already exists with this email address.")
return {
'email': email,
'first_name': first_name,
'last_name': last_name,
}
def save(self, commit=True):
self.user.email = self.cleaned_data['email']
self.user.first_name = self.cleaned_data['first_name']
self.user.last_name = self.cleaned_data['last_name']
if commit:
self.user.save()
return self.user

58
accounts/login.py Normal file
View File

@ -0,0 +1,58 @@
from django.shortcuts import render, redirect
from django.contrib.auth.forms import AuthenticationForm
from .forms import EnableTotpForm
from django.contrib.auth import login
from .models import Account
from django.contrib.auth.models import User
from django.contrib import messages
import pyotp
from time import sleep
def log_in(request):
if request.user.is_authenticated:
return redirect('audio:home')
if request.method == "POST":
form = AuthenticationForm(data=request.POST)
if form.is_valid():
user = form.get_user()
if not hasattr(user, 'account'):
account = Account(user=user)
account.save()
user.account = account
if user.account.use_totp:
request.session['user_id'] = user.id
request.session['totp_timeout'] = 1
return redirect('accounts:two_factor_input')
else:
login(request, user)
messages.success(request, 'Successfully logged in!', extra_tags="mb-0")
return redirect('audio:home')
else:
form = AuthenticationForm()
return render(request, 'base_form.html', {'form': form})
def two_factor_input(request):
if request.user.is_authenticated:
return redirect('audio:home')
if 'user_id' not in request.session:
return redirect('audio:home')
user = User.objects.get(id=request.session['user_id'])
if request.method == "POST":
form = EnableTotpForm(request.POST, instance=user.account)
if form.is_valid():
totp_code = form.cleaned_data['totp_code']
if pyotp.TOTP(user.account.totp_key).verify(int(totp_code), valid_window=5):
login(request, user)
del request.session['user_id']
messages.success(request, 'Successfully logged in!', extra_tags="mb-0")
return redirect('audio:home')
else:
form = EnableTotpForm(instance=user.account)
messages.error(request, 'Wrong Code, try again?', extra_tags="mb-0")
sleep(request.session['totp_timeout'])
request.session['totp_timeout'] = request.session['totp_timeout'] * 2
else:
form = EnableTotpForm(instance=user.account)
return render(request, 'accounts/totp_form.html', {'form': form})

View File

@ -0,0 +1,30 @@
# Generated by Django 3.1.6 on 2021-02-21 22:18
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Account',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('totp_key', models.CharField(max_length=16, null=True)),
('use_totp', models.BooleanField(default=False)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

12
accounts/models.py Normal file
View File

@ -0,0 +1,12 @@
from django.db import models
from tp.models import UUIDAsIDModel
from django.contrib.auth.models import User
class Account(UUIDAsIDModel):
user = models.OneToOneField(User, on_delete=models.CASCADE, unique=True)
totp_key = models.CharField(max_length=16, null=True)
use_totp = models.BooleanField(default=False)
def __str__(self):
return str(self.user)

View File

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
{% include "base_navbar.html" %}
{% include "base_heading.html" %}
<div class="containe mt-4">
<div class="d-flex justify-content-center">
<style> svg { transform: scale(1.5); } </style>
{{ qr | safe }}
</div>
</div>
<div class="container">
<form method="POST" class="d-flex flex-column col-10 offset-1 col-md-2 offset-md-5 mt-5 align-items-center">
{% csrf_token %}
{{ form | crispy }}
<div class="text-center">
<input type="submit" class="btn btn-dark btn-lg mt-5" value="Submit">
</div>
</form>
</div>
{% endblock %}

3
accounts/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

16
accounts/urls.py Normal file
View File

@ -0,0 +1,16 @@
from django.urls import path
from .enable_totp import enable_totp, disable_totp
from .login import log_in, two_factor_input
from . import views
app_name = "accounts"
urlpatterns = [
path('login/', log_in, name='login'),
path('logout/', views.log_out, name='logout'),
path('edit-profile/', views.edit_profile, name='edit_profile'),
path('password-change/', views.password_change, name='password_change'),
path('enable-totp/', enable_totp, name='enable_totp'),
path('disable-totp/', disable_totp, name='disable_totp'),
path('two-factor-input/', two_factor_input, name='two_factor_input'),
]

48
accounts/views.py Normal file
View File

@ -0,0 +1,48 @@
from django.shortcuts import render, redirect
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib import messages
from django.contrib.auth import logout, update_session_auth_hash
from .forms import EditProfileForm
def password_change(request):
if not request.user.is_authenticated:
return redirect('audio:home')
if request.method == "POST":
form = PasswordChangeForm(request.user, request.POST)
if form.is_valid():
user = form.save()
update_session_auth_hash(request, user)
messages.success(request, 'Your password was successfully updated!', extra_tags="mb-0")
return redirect('accounts:edit_profile')
else:
form = PasswordChangeForm(request.user)
return render(request, 'base_form.html', {'form': form})
def log_out(request):
if not request.user.is_authenticated:
return redirect('audio:home')
if request.method == "POST":
logout(request)
messages.success(request, 'Successfully Logged Out!', extra_tags="mb-0")
return redirect('audio:home')
return render(request, 'confirmation.html', {})
def edit_profile(request):
if not request.user.is_authenticated:
return redirect('audio:home')
if request.method == "POST":
form = EditProfileForm(request.user, request.POST)
if form.is_valid():
form.save()
messages.success(request, 'Your profile was successfully updated!', extra_tags="mb-0")
return redirect('audio:home')
else:
form = EditProfileForm(request.user, initial={
'email': request.user.email,
'first_name': request.user.first_name,
'last_name': request.user.last_name,
})
return render(request, 'base_form.html', {'form': form})

0
audio/__init__.py Normal file
View File

3
audio/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
audio/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AudioConfig(AppConfig):
name = 'audio'

92
audio/audiorssfeed.py Normal file
View File

@ -0,0 +1,92 @@
from django.contrib.syndication.views import Feed as RSSFeed
from django.contrib.sites.shortcuts import get_current_site
from django.urls import reverse
from django.utils.feedgenerator import Rss201rev2Feed
from tp.settings import IMAGES_URL, MP3_URL
from .models import Feed
from datetime import datetime
class AudioRssFeedGenerator(Rss201rev2Feed):
content_type = 'application/xml; charset=utf-8'
def add_root_elements(self, handler):
super().add_root_elements(handler)
handler.startElement("image", {})
handler.addQuickElement("url", self.feed['image_url'])
handler.addQuickElement("title", self.feed['image_title'])
handler.addQuickElement("link", self.feed['image_link'])
handler.addQuickElement("description", self.feed['image_desc'])
handler.endElement("image")
def add_item_elements(self, handler, item):
super().add_item_elements(handler, item)
handler.startElement("image", {})
handler.addQuickElement("url", item['image_url'])
handler.addQuickElement("title", item['image_title'])
handler.addQuickElement("link", item['image_link'])
handler.addQuickElement("description", item['image_desc'])
handler.endElement("image")
class AudioRssFeed(RSSFeed):
feed_type = AudioRssFeedGenerator
def get_object(self, request, slug):
obj = Feed.objects.get(slug=slug)
obj.request = request
return obj
def items(self, obj):
xr = [x for x in obj.episode_set.order_by('pub_date')]
for x in xr:
x.request = obj.request
return xr
def item_enclosure_url(self, item):
return f'{MP3_URL}{item.mp3}'
def item_enclosure_length(self, item):
return item.image.size
def item_enclosure_mime_type(self, item):
return "audio/mpeg"
def item_pubdate(self, item):
'''
Need to return datetime.datetime object,
but item.pub_date is an datetime.date object
'''
return datetime.fromisoformat(item.pub_date.isoformat())
def link(self, obj):
return reverse('audio:feed', kwargs={'pk': obj.pk, 'slug': obj.slug})
def title(self, obj):
return obj.title
def description(self, obj):
return obj.description
def item_link(self, item):
return reverse('audio:episode', kwargs={'pk': item.pk, 'slug': item.slug})
def item_title(self, item):
return f'{item.episode_number}: {item.title}'
def item_extra_kwargs(self, item):
x = {}
x['image_url'] = f'{IMAGES_URL}{item.image.name}'
x['image_title'] = item.title
x['image_link'] = f'{get_current_site(item.request)}{self.item_link(item)}'
x['image_desc'] = f'Image for: {item.title}'
return x
def feed_extra_kwargs(self, obj):
x = {}
x['image_url'] = f'{IMAGES_URL}{obj.image.name}'
x['image_title'] = obj.title
x['image_link'] = f'{get_current_site(obj.request)}{self.link(obj)}'
x['image_desc'] = f'Image for: {obj.title}'
return x

54
audio/episode_views.py Normal file
View File

@ -0,0 +1,54 @@
from django.shortcuts import render, redirect
from .forms import EpisodeForm
from .models import Feed, Episode
def edit_episode(request, pk, title_slug):
if not request.user.is_authenticated:
return redirect('audio:home')
episode = Episode.objects.get(id=pk)
if not episode.user == request.user:
return redirect('audio:home')
if request.method == "POST":
form = EpisodeForm(request.POST, request.FILES, instance=episode)
if form.is_valid():
form.save()
return redirect('audio:home')
else:
form = EpisodeForm(instance=episode)
return render(
request, 'base_form.html',
{
'form': form,
'heading': 'Edit Episode?',
'title': 'Edit Episode?',
'submit': 'save',
'form_data': 'TRUE',
})
def new_episode(request, feed_pk, feed_title_slug):
if not request.user.is_authenticated:
return redirect('audio:home')
feed = Feed.objects.get(id=feed_pk)
if not feed.user == request.user:
return redirect('audio:home')
if request.method == "POST":
form = EpisodeForm(request.POST, request.FILES)
if form.is_valid():
episode = form.save(commit=False)
episode.user = request.user
episode.feed = feed
episode.save()
return redirect('audio:new_feed')
else:
form = EpisodeForm()
return render(
request, 'base_form.html',
{
'form': form,
'heading': 'New Episode?',
'title': 'New Episode?',
'submit': 'submit',
'form_data': 'TRUE',
})

24
audio/forms.py Normal file
View File

@ -0,0 +1,24 @@
from .models import Feed, Episode
from django import forms
class FeedForm(forms.ModelForm):
class Meta:
model = Feed
fields = [
'title', 'author', 'description', 'image'
]
class EpisodeForm(forms.ModelForm):
pub_date = forms.DateField(
widget=forms.TextInput(attrs={'type': 'date'})
)
class Meta:
model = Episode
fields = [
'title', 'author', 'pub_date', 'episode_number', 'description', 'image', 'mp3'
]

View File

@ -0,0 +1,31 @@
# Generated by Django 3.1.6 on 2021-02-22 06:44
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Feed',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_on', models.DateTimeField(auto_now_add=True)),
('title', models.CharField(max_length=120)),
('author', models.CharField(max_length=120)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 3.1.6 on 2021-02-22 06:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audio', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='feed',
name='slug',
field=models.SlugField(max_length=255, null=True, unique=True),
),
migrations.AlterField(
model_name='feed',
name='author',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='feed',
name='title',
field=models.CharField(max_length=255),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1.6 on 2021-02-22 06:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audio', '0002_auto_20210221_2256'),
]
operations = [
migrations.AlterField(
model_name='feed',
name='slug',
field=models.SlugField(max_length=255, unique=True),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.1.6 on 2021-02-22 21:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audio', '0003_auto_20210221_2256'),
]
operations = [
migrations.AddField(
model_name='feed',
name='description',
field=models.TextField(default=''),
preserve_default=False,
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.1.6 on 2021-02-23 00:02
from django.db import migrations, models
import tp.storage_backends
class Migration(migrations.Migration):
dependencies = [
('audio', '0004_feed_description'),
]
operations = [
migrations.AddField(
model_name='feed',
name='image',
field=models.ImageField(null=True, storage=tp.storage_backends.PublicImageStorage(), upload_to=''),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.1.6 on 2021-02-23 00:03
from django.db import migrations, models
import tp.storage_backends
class Migration(migrations.Migration):
dependencies = [
('audio', '0005_feed_image'),
]
operations = [
migrations.AlterField(
model_name='feed',
name='image',
field=models.ImageField(blank=True, null=True, storage=tp.storage_backends.PublicImageStorage(), upload_to=''),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.1.6 on 2021-02-23 05:00
import audio.models
from django.db import migrations, models
import tp.storage_backends
class Migration(migrations.Migration):
dependencies = [
('audio', '0006_auto_20210222_1603'),
]
operations = [
migrations.AlterField(
model_name='feed',
name='image',
field=models.ImageField(blank=True, null=True, storage=tp.storage_backends.PublicImageStorage(), upload_to=audio.models.slugify_file_name),
),
]

View File

@ -0,0 +1,42 @@
# Generated by Django 3.1.6 on 2021-02-23 08:18
import audio.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import tp.storage_backends
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('audio', '0007_auto_20210222_2100'),
]
operations = [
migrations.AlterField(
model_name='feed',
name='image',
field=models.ImageField(blank=True, null=True, storage=tp.storage_backends.PublicImageStorage(), upload_to=audio.models.slugify_file_name),
),
migrations.CreateModel(
name='Episode',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=255)),
('author', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('description', models.TextField()),
('created_on', models.DateTimeField(auto_now_add=True)),
('image', models.ImageField(blank=True, null=True, storage=tp.storage_backends.PublicImageStorage(), upload_to=audio.models.slugify_file_name)),
('mp3', models.FileField(blank=True, null=True, storage=tp.storage_backends.PublicMP3Storage(), upload_to=audio.models.slugify_file_name)),
('feed', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='audio.feed')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.1.6 on 2021-02-23 08:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audio', '0008_auto_20210223_0018'),
]
operations = [
migrations.AddField(
model_name='episode',
name='pub_date',
field=models.DateField(default=None),
preserve_default=False,
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1.7 on 2021-02-24 01:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audio', '0009_episode_pub_date'),
]
operations = [
migrations.AddField(
model_name='episode',
name='episode_number',
field=models.IntegerField(null=True),
),
]

View File

65
audio/models.py Normal file
View File

@ -0,0 +1,65 @@
from django.db import models
from tp.models import UUIDAsIDModel
from django.contrib.auth.models import User
from django.utils.text import slugify
from tp.storage_backends import PublicImageStorage, PublicMP3Storage
import string, random
def rand_slug():
return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(8))
def slugify_file_name(instance, filename):
fname, dot, extension = filename.rpartition('.')
slug = slugify(fname)
return f'{slug}.{extension}'
class Feed(UUIDAsIDModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
created_on = models.DateTimeField(auto_now_add=True)
title = models.CharField(max_length=255)
author = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
description = models.TextField(null=False)
image = models.ImageField(
storage=PublicImageStorage(),
upload_to=slugify_file_name,
null=True, blank=True)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(rand_slug() + "-" + self.title)
super(Feed, self).save(*args, **kwargs)
def __str__(self):
return str(self.title)
class Episode(UUIDAsIDModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
feed = models.ForeignKey(Feed, on_delete=models.CASCADE)
title = models.CharField(max_length=255)
author = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
description = models.TextField(null=False)
created_on = models.DateTimeField(auto_now_add=True)
pub_date = models.DateField()
episode_number = models.IntegerField(null=True)
image = models.ImageField(
storage=PublicImageStorage(),
upload_to=slugify_file_name,
null=True, blank=True)
mp3 = models.FileField(
storage=PublicMP3Storage(),
upload_to=slugify_file_name,
null=True, blank=True)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(rand_slug() + "-" + self.title)
super(Episode, self).save(*args, **kwargs)
def __str__(self):
return str(self.title)

View File

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
{% include "base_navbar.html" %}
{% include "base_heading.html" %}
<div id="main" class="mx-0 px-0">
<div class="row w-100 mx-0">
<div class="col-0 col-sm-3">
</div>
<div class="col-12 col-sm-6 px-0 mx-0">
{% for i in feeds %}
<div class="card mx-1 mb-3">
<h3 class="text-center mt-2">
{{ i.title }}
</h3>
<div class="container w-100 mb-2">
<div class="row">
<div class="col-3 d-flex flex-column justify-content-center">
<img src="{{ IMAGES_URL }}{{ i.image }}">
</div>
<div class="col-9 d-flex flex-row justify-content-center">
<div class="d-flex flex-column justify-content-center">
<p>{{ i.created_on }}</p>
<p><a href="{% url 'audio:feed' pk=i.pk slug=i.slug %}">{{ i.title }}</a></p>
<p><a href="{% url 'audio:rss' slug=i.slug %}">RSS</a></p>
<p>{{ i.description }}</p>
</div>
</div>
</div>
</div>
{% if user.is_authenticated %}
{% if user == i.user %}
<div class="container w-100 d-flex justify-content-around my-1">
<div>
<a href="{% url 'audio:edit_feed' pk=i.pk title_slug=i.slug %}">Edit Feed?</a>
</div>
<div>
<a href="{% url 'audio:new_episode' feed_pk=i.pk feed_title_slug=i.slug %}">New Episode?</a>
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
<div class="col-0 col-sm-3">
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block content %}
{% include "base_navbar.html" %}
{% include "base_heading.html" %}
<div id="main" class="mx-0 px-0">
<div class="row w-100 mx-0">
<div class="col-0 col-sm-3">
</div>
<div class="col-12 col-sm-6 px-0 mx-0">
{% for j in episodes %}
<div class="card mx-1 mb-3">
<h3 class="text-center mt-2">
{{ j.episode_number }}. {{ j.title }}
</h3>
<div class="container w-100 mb-2">
<div class="row">
<div class="col-3 d-flex flex-column justify-content-center">
<img src="{{ IMAGES_URL }}{{ j.image }}">
</div>
<div class="col-9 d-flex flex-row justify-content-center">
<div class="d-flex flex-column justify-content-center">
<p>{{ j.pub_date }}</p>
<p><a href="{% url 'audio:episode' pk=j.pk slug=j.slug %}">{{ j.title }}</a></p>
<p><a href="{% url 'audio:rss' slug=j.feed.slug %}">RSS</a></p>
<p>{{ j.description }}</p>
</div>
</div>
</div>
</div>
<div class="container w-100 mb-2 text-center">
<audio controls class="btn btn-dark w-100">
<source src="{{ MP3_URL }}{{ j.mp3 }}" type="audio/mpeg">
Your browser does not support the audio tag.
</audio>
</div>
{% if user.is_authenticated %}
{% if user == j.user %}
<div class="container w-100 d-flex justify-content-around my-1">
<div>
<a href="{% url 'audio:edit_episode' pk=j.pk title_slug=j.slug %}">Edit Episode?</a><br>
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
<div class="col-0 col-sm-3">
</div>
</div>
</div>
{% endblock %}
{% for j in episodes %}
<div class="container text-center">
<h3>
{{ j.title }}
</h3>
<a href="{% url 'audio:episode' pk=j.pk slug=j.slug %}">{{ j.title }}</a>
<p>
{{ j.pub_date }}
</p>
<p>
{{ j.description }}
</p>
<div class="container w-25 h-25">
<img class="w-50 h-50" src="{{ IMAGES_URL }}{{ j.image }}">
</div>
<a href="{% url 'audio:rss' slug=j.feed.slug %}">RSS</a><br>
<audio controls class="btn btn-dark">
<source src="{{ MP3_URL }}{{ j.mp3 }}" type="audio/mpeg">
Your browser does not support the audio tag.
</audio>
{% if user.is_authenticated %}
{% if user == j.user %}
<div class="container w-25 h-25 mt-3">
<a href="{% url 'audio:edit_episode' pk=j.pk title_slug=j.slug %}">Edit Episode?</a><br>
</div>
{% endif %}
{% endif %}
</div>
{% endfor %}

3
audio/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

18
audio/urls.py Normal file
View File

@ -0,0 +1,18 @@
from django.urls import path
from . import views
from .episode_views import new_episode, edit_episode
from .audiorssfeed import AudioRssFeed
app_name = "audio"
urlpatterns = [
path('', views.home, name='home'),
path('new-feed/', views.new_feed, name='new_feed'),
path('feeds/', views.feeds, name='feeds'),
path('edit-feed/<str:pk>/<str:title_slug>', views.edit_feed, name='edit_feed'),
path('new-episode/<str:feed_pk>/<str:feed_title_slug>', new_episode, name='new_episode'),
path('edit-episode/<str:pk>/<str:title_slug>', edit_episode, name='edit_episode'),
path('rss/<str:slug>.xml', AudioRssFeed(), name='rss'),
path('feed/<str:pk>/<str:slug>', views.feed, name='feed'),
path('episode/<str:pk>/<str:slug>', views.episode, name='episode'),
]

80
audio/views.py Normal file
View File

@ -0,0 +1,80 @@
from django.shortcuts import render, redirect
from .forms import FeedForm
from .models import Feed, Episode
from tp.settings import IMAGES_URL, MP3_URL
def home(request):
episodes = Episode.objects.all()
return render(
request,
'audio/index.html',
{'episodes': episodes, 'IMAGES_URL': IMAGES_URL, 'MP3_URL': MP3_URL})
def feed(request, pk, slug):
feed = Feed.objects.get(id=pk)
episodes = feed.episode_set.all()
return render(
request, 'audio/index.html',
{
'episodes': episodes, 'IMAGES_URL': IMAGES_URL,
'MP3_URL': MP3_URL, 'title': feed.title, 'heading': feed.title
})
def episode(request, pk, slug):
episode = Episode.objects.get(id=pk)
return render(
request, 'audio/index.html',
{
'episodes': (episode, ), 'IMAGES_URL': IMAGES_URL,
'MP3_URL': MP3_URL, 'title': episode.title, 'heading': episode.title
})
def feeds(request):
feeds = Feed.objects.all().order_by('-created_on')
return render(
request,
'audio/feeds.html',
{'feeds': feeds, 'IMAGES_URL': IMAGES_URL})
def new_feed(request):
if not request.user.is_authenticated:
return redirect('audio:home')
if request.method == "POST":
form = FeedForm(request.POST, request.FILES)
if form.is_valid():
feed = form.save(commit=False)
feed.user = request.user
feed.save()
return redirect('audio:new_feed')
else:
form = FeedForm()
return render(request, 'base_form.html', {'form': form})
def edit_feed(request, pk, title_slug):
if not request.user.is_authenticated:
return redirect('audio:home')
feed = Feed.objects.get(id=pk)
if not feed.user == request.user:
return redirect('audio:home')
if request.method == "POST":
form = FeedForm(request.POST, request.FILES, instance=feed)
if form.is_valid():
feed.save()
return redirect('audio:new_feed')
else:
form = FeedForm(instance=feed)
return render(
request, 'base_form.html',
{
'form': form,
'heading': 'Edit Feed?',
'title': 'Edit Feed?',
'submit': 'save',
'form_data': 'TRUE',
})

22
manage.py Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tp.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

0
tp/__init__.py Normal file
View File

16
tp/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for tp project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tp.settings')
application = get_asgi_application()

9
tp/models.py Normal file
View File

@ -0,0 +1,9 @@
from django.db import models
import uuid
class UUIDAsIDModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class Meta:
abstract = True

141
tp/settings.py Normal file
View File

@ -0,0 +1,141 @@
from pathlib import Path
import os
from dotenv import load_dotenv
load_dotenv()
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = str(os.getenv('SECRET_KEY'))
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True if str(os.getenv('DEBUG')) == "True" else False
ALLOWED_HOSTS = [x for x in os.environ.get('ALLOWED_HOSTS').split(' ')]
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'bootstrap4',
'crispy_forms',
'audio.apps.AudioConfig',
'accounts.apps.AccountsConfig',
'storages',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'tp.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['tp/templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'tp.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
CRISPY_TEMPLATE_PACK = 'bootstrap4'
# Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'America/Los_Angeles'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
# STATIC_URL = '/static/'
# aws settings
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME')
AWS_S3_ENDPOINT_URL = os.getenv('AWS_S3_ENDPOINT_URL')
AWS_DEFAULT_ACL = None
# AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
AWS_S3_CUSTOM_DOMAIN = os.getenv('AWS_S3_CUSTOM_DOMAIN')
AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'}
# s3 static settings
STATIC_LOCATION = 'static'
STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/'
STATICFILES_STORAGE = 'tp.storage_backends.StaticStorage'
# s3 public media settings
PUBLIC_MP3_LOCATION = 'mp3'
MP3_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_MP3_LOCATION}/'
MP3_FILE_STORAGE = 'tp.storage_backends.PublicMP3Storage'
PUBLIC_IMAGES_LOCATION = 'images'
IMAGES_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_IMAGES_LOCATION}/'
IMAGES_FILE_STORAGE = 'tp.storage_backends.PublicImageStorage'
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'),)

18
tp/storage_backends.py Normal file
View File

@ -0,0 +1,18 @@
from storages.backends.s3boto3 import S3Boto3Storage
# from django.conf import settings
class StaticStorage(S3Boto3Storage):
location = 'static'
default_acl = 'public-read'
class PublicMP3Storage(S3Boto3Storage):
location = 'mp3'
default_acl = 'public-read'
# file_overwrite = False
class PublicImageStorage(S3Boto3Storage):
location = 'images'
default_acl = 'public-read'

49
tp/templates/base.html Normal file
View File

@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>
{% url 'audio:home' as home_url %}
{% url 'accounts:login' as login_url %}
{% url 'accounts:logout' as logout_url %}
{% url 'accounts:edit_profile' as edit_profile_url %}
{% url 'accounts:password_change' as password_change_url %}
{% url 'accounts:enable_totp' as enable_totp_url %}
{% url 'accounts:disable_totp' as disable_totp_url %}
{% url 'audio:new_feed' as new_feed_url %}
{% url 'audio:feeds' as feeds_url %}
{% if request.path == home_url %}
Home
{% elif request.path == login_url %}
Login?
{% elif request.path == logout_url %}
Logout?
{% elif request.path == edit_profile_url %}
Edit Profile?
{% elif request.path == password_change_url %}
Change Password?
{% elif request.path == enable_totp_url %}
Enable 2fa?
{% elif request.path == disable_totp_url %}
Disable 2fa?
{% elif request.path == new_feed_url %}
New Feed?
{% elif request.path == feeds_url %}
Feeds
{% endif %}
{{ title }}
</title>
{% load bootstrap4 %}
{% bootstrap_css %}
{% bootstrap_messages %}
</head>
<body>
{% block content %}{% endblock content %}
{% bootstrap_javascript jquery='full' %}
</body>
</html>

View File

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
{% url 'accounts:login' as login_url %}
{% url 'accounts:edit_profile' as edit_profile_url %}
{% url 'accounts:password_change' as password_change_url %}
{% url 'audio:new_feed' as new_feed_url %}
{% if request.path == login_url %}
{% firstof 'Login' as submit %}
{% elif request.path == edit_profile_url %}
{% firstof 'Update' as submit %}
{% elif request.path == password_change_url %}
{% firstof 'Update' as submit %}
{% elif request.path == new_feed_url %}
{% firstof 'Submit' as submit %}
{% endif %}
{% include "base_navbar.html" %}
{% include "base_heading.html" %}
<div class="container">
<div class="d-flex flex-column offset-sm-3 col-sm-6 col-xs-12 px-0">
{% if request.path == new_feed_url %}
<form method="POST" enctype="multipart/form-data">
{% elif form_data == "TRUE" %}
<form method="POST" enctype="multipart/form-data">
{% else %}
<form method="POST">
{% endif %}
{% csrf_token %}
{{ form | crispy }}
<div class="mt-3">
<input type="submit" class="btn btn-dark btn-lg" value="{{ submit }}">
</div>
</form><br>
{% if request.path == edit_profile_url %}
<div class="container px-0">
<div class="d-flex flex-column align-items-start">
{% if user.account.use_totp %}
<a type="button" class="btn btn-lg btn-dark mb-4" href="{% url 'accounts:disable_totp' %}">Disable 2fa</a>
{% else %}
<a type="button" class="btn btn-lg btn-dark mb-4" href="{% url 'accounts:enable_totp' %}">Enable 2fa</a>
{% endif %}
<a type="button" class="btn btn-lg btn-dark" href="{% url 'accounts:password_change' %}">Change Password</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,35 @@
<div class="container">
<div class="row justify-content-center my-2 mx-0">
<h1 class="">
{% url 'accounts:login' as login_url %}
{% url 'accounts:edit_profile' as edit_profile_url %}
{% url 'accounts:password_change' as password_change_url %}
{% url 'accounts:enable_totp' as enable_totp_url %}
{% url 'accounts:disable_totp' as disable_totp_url %}
{% url 'audio:new_feed' as new_feed_url %}
{% url 'audio:feeds' as feeds_url %}
{% url 'audio:home' as home_url %}
{% if request.path == login_url %}
Login?
{% elif request.path == edit_profile_url %}
Edit Profile?
{% elif request.path == password_change_url %}
Change Password?
{% elif request.path == enable_totp_url %}
Enable 2fa?
{% elif request.path == disable_totp_url %}
Disable 2fa?
{% elif request.path == new_feed_url %}
New Feed?
{% elif request.path == feeds_url %}
Feeds
{% elif request.path == home_url %}
Home
{% endif %}
{{ heading }}
</h1>
</div>
</div>

View File

@ -0,0 +1,20 @@
<div class="d-flex justify-content-center bg-dark">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark" style="width: 950px; max-width: 95vw;">
<a class="navbar-brand" href="{% url 'audio:home' %}">Home</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavAltMarkup" aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<div class="navbar-nav">
<a class="nav-item nav-link" href="{% url 'audio:feeds' %}">Feeds</a>
{% if user.is_authenticated %}
<a class="nav-item nav-link" href="{% url 'accounts:logout' %}">Logout</a>
<a class="nav-item nav-link" href="{% url 'accounts:edit_profile' %}">Profile</a>
<a class="nav-item nav-link" href="{% url 'audio:new_feed' %}">NewFeed</a>
{% else %}
<a class="nav-item nav-link" href="{% url 'accounts:login' %}">Login</a>
{% endif %}
</div>
</div>
</nav>
</div>

View File

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
{% url 'accounts:logout' as logout_url %}
{% url 'accounts:disable_totp' as disable_totp_url %}
{% if request.path == logout_url %}
{% firstof 'Logout' as submit %}
{% elif request.path == disable_totp_url %}
{% firstof 'OK' as submit %}
{% endif %}
{% include "base_navbar.html" %}
<div style="height: 10vh;"></div>
{% include "base_heading.html" %}
<div style="height: 30vh;" class="">
<div class="h-100 col-sm-12 col-xs-12 d-flex justify-content-center align-content-center row p-0 mx-0">
<form method="POST">
{% csrf_token %}
<input type="submit" class="btn btn-dark btn-lg" value="{{ submit }}">
</form>
</div>
</div>
{% endblock %}

23
tp/urls.py Normal file
View File

@ -0,0 +1,23 @@
"""tp URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('audio.urls')),
path('accounts/', include('accounts.urls')),
]

16
tp/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for tp project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tp.settings')
application = get_wsgi_application()