initial commit.

This commit is contained in:
Chris GIACOFEI 2024-05-30 16:26:55 -04:00
commit 019f7b5b19
65 changed files with 2212 additions and 0 deletions

52
README.md Normal file
View File

@ -0,0 +1,52 @@
## Compose sample application
### Django application in dev mode
Project structure:
```
.
├── compose.yaml
├── app
   ├── Dockerfile
   ├── requirements.txt
   └── manage.py
```
[_compose.yaml_](compose.yaml)
```
services:
web:
build: app
ports:
- '8000:8000'
```
## Deploy with docker compose
```
$ docker compose up -d
Creating network "django_default" with the default driver
Building web
Step 1/6 : FROM python:3.7-alpine
...
...
Status: Downloaded newer image for python:3.7-alpine
Creating django_web_1 ... done
```
## Expected result
Listing containers must show one container running and the port mapping as below:
```
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3adaea94142d django_web "python3 manage.py r…" About a minute ago Up About a minute 0.0.0.0:8000->8000/tcp django_web_1
```
After the application starts, navigate to `http://localhost:8000` in your web browser:
Stop and remove the containers
```
$ docker compose down
```

0
beer/__init__.py Normal file
View File

39
beer/admin.py Normal file
View File

@ -0,0 +1,39 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from beer.models import Batch, Recipe, BatchRecipe
from yeast.models import Yeast
from config.extras import BREWFATHER_APP_ROOT
import logging
logger = logging.getLogger('django')
class SampleInline(admin.TabularInline):
model = Yeast
extra = 0
class RecipeAdmin(admin.ModelAdmin):
list_display = ['name']
class BatchRecipeAdmin(admin.ModelAdmin):
list_display = ['name']
class BeerBatchAdmin(admin.ModelAdmin):
list_display = ['brewfather_id', 'batch_url']
inlines = [
SampleInline,
]
url_string = "<a href='{root}/tabs/batches/batch/{batch_id}'>Brewfather Batch ID: {batch_id}</a>"
def batch_url(self, obj):
bf_id = obj.brewfather_id
return format_html("<a href='{root}/tabs/batches/batch/{batch_id}'>Brewfather App: {batch_id}</a>", batch_id=bf_id, root=BREWFATHER_APP_ROOT)
admin.site.register(Batch, BeerBatchAdmin)
admin.site.register(Recipe, RecipeAdmin)
admin.site.register(BatchRecipe, BatchRecipeAdmin)

6
beer/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class BeerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'beer'

View File

@ -0,0 +1,28 @@
# Generated by Django 5.0.6 on 2024-05-30 12:01
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Batch',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('brewfather_id', models.CharField(max_length=50)),
('brewfather_num', models.IntegerField(default=1)),
('brewfather_name', models.CharField(default='name', max_length=500)),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 5.0.6 on 2024-05-30 12:26
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='BatchRecipe',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('name', models.CharField(max_length=50)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Recipe',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('name', models.CharField(max_length=50)),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.0.6 on 2024-05-30 12:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0002_batchrecipe_recipe'),
]
operations = [
migrations.AddField(
model_name='batch',
name='recipe',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='beer.batchrecipe'),
),
]

View File

32
beer/models.py Normal file
View File

@ -0,0 +1,32 @@
from django.db import models
from django.utils import timezone
import logging
logger = logging.getLogger('django')
class CustomModel(models.Model):
""" Custom model class with default fields to use. """
created_date = models.DateTimeField(default=timezone.now)
class Meta:
abstract = True
class Batch(CustomModel):
brewfather_id = models.CharField(max_length=50)
brewfather_num = models.IntegerField(default=1)
brewfather_name = models.CharField(max_length=500, default='name')
recipe = models.ForeignKey('BatchRecipe', on_delete=models.CASCADE, default=1)
def __str__(self):
# Return a string that represents the instance
return 'BF #{num}: {name}'.format(name=self.brewfather_name, num=self.brewfather_num)
class BatchRecipe(CustomModel):
""" Recipe to be stored with a batch."""
name = models.CharField(max_length=50)
class Recipe(CustomModel):
""" Recipes not attched to batches."""
name = models.CharField(max_length=50)

8
beer/urls.py Normal file
View File

@ -0,0 +1,8 @@
from django.urls import include, path
from django.contrib import admin
from django.contrib.flatpages import views
urlpatterns = [
]

0
config/__init__.py Normal file
View File

166
config/extras.py Normal file
View File

@ -0,0 +1,166 @@
import labels
from django.urls import reverse
from reportlab.graphics import shapes
from reportlab.graphics.barcode import createBarcodeDrawing
from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.lib.pagesizes import LETTER
from config.settings import DEBUG
BREWFATHER_APP_ROOT = 'https://web.brewfather.app'
class AveryLabel:
"""
test_labels = [
{'manufacturer':'SafAle', 'name':'S-04', 'date':'2024-05-01', 'id':'12345'},
{'manufacturer':'SafAle', 'name':'US-05', 'date':'2024-05-15', 'id':'23456'}
]
labelSheet = AveryLabel(18294, debug=True)
labelSheet.render(test_labels, 'filelabels.pdf', 5)
"""
AVERY = {
18294: ( 4, 15, LETTER, (44.45, 16.764), (7.62, 13.97)),
5263: ( 2, 5, LETTER, (101.6, 50.8), (3.95, 12.7)),
}
def __init__(self, label, **kwargs):
data = self.AVERY[label]
self.across = data[0]
self.down = data[1]
self.pagesize = data[2]
self.labelsize = data[3]
self.margins = data[4]
self.topDown = True
self.debug = False
self.position = 0
self.previous_used = 0
self.corner_radius = 2
self.__dict__.update(kwargs)
self.specs = labels.Specification(
self.pagesize[0]*0.352778, self.pagesize[1]*0.352778, # Convert from PostScript points to mm
self.across, self.down,
self.labelsize[0], self.labelsize[1],
corner_radius=self.corner_radius,
left_margin=self.margins[0], right_margin=self.margins[0], top_margin=self.margins[1],
left_padding=2, right_padding=1, top_padding=1, bottom_padding=0,
row_gap=0)
# def draw_sample(self, label, width, height, obj):
# if not obj['blank']:
# obj['path'] = reverse("yeast:sample", kwargs={"sample_id": obj['id']})
# # Barcode
# if DEBUG:
# qrw = QrCodeWidget('https://yeast.giacofei.org/{path}'.format(**obj))
# else:
# qrw = QrCodeWidget('https://{host}/{path}'.format(**obj))
# b = qrw.getBounds()
# w=b[2]-b[0]
# h=b[3]-b[1]
# psSize = (self.labelsize[1]/0.352778)
# d = shapes.Drawing(w,h, transform=[(4.5/6) * (psSize/w),0,0,(4.5/6) * (psSize/h),0,0])
# d.add(qrw)
# label.add(d)
# # Title
# label.add(shapes.String(
# 0, # Left Position
# 5 * height / 6, # Bottom Position
# '{manufacturer} {name}'.format(**obj), # Text
# fontName="Helvetica",
# fontSize=10
# ))
# # Line
# line_pos = 4.5 * height / 6
# label.add(shapes.Line(0, line_pos, width,line_pos))
# # Metadata
# label.add(shapes.String(
# height*.75, # Left Position
# 3 * height / 6, # Bottom Position
# 'ID: {id}'.format(**obj), # Text
# fontName="Helvetica",
# fontSize=6
# ))
# label.add(shapes.String(
# height*.75, # Left Position
# 2 * height / 6, # Bottom Position
# 'Packaging Date: {date}'.format(**obj), # Text
# fontName="Helvetica",
# fontSize=6
# ))
def draw(self, label, width, height, obj):
if not obj['blank']:
obj['path'] = reverse(
"{}:{}".format(obj['ns'],obj['template']),
kwargs={"{}_id".format(obj['template']): obj['id']}
)
# Barcode
if DEBUG:
qrw = QrCodeWidget('https://brewery.giacofei.org/{path}'.format(**obj))
else:
qrw = QrCodeWidget('https://{host}/{path}'.format(**obj))
num_lines = max(len(obj['data']) + 1, 6)
font = height/num_lines
b = qrw.getBounds()
w=b[2]-b[0]
h=b[3]-b[1]
psSize = (self.labelsize[1]/0.352778)
d = shapes.Drawing(w,h, transform=[(psSize/w),0,0,(psSize/h),0,0])
d.add(qrw)
label.add(d)
# Line
line_pos = (num_lines - 1) * height / num_lines
label.add(shapes.Line(height, line_pos, width,line_pos))
# Title
label.add(shapes.String(
height, # Left Position
line_pos * 1.05, # Bottom Position
'{title}'.format(**obj), # Text
fontName="Helvetica",
fontSize=font
))
for x, line in enumerate(obj['data']):
x=x+1
label.add(shapes.String(
height, # Left Position
line_pos - font * x, # Bottom Position
line, # Text
fontName="Helvetica",
fontSize=font
))
def render(self, objects, render_file, labels_used=0):
"""
# Create the HttpResponse object with the appropriate PDF headers.
response = HttpResponse(mimetype='application/pdf')
response['Content-Disposition'] = 'attachment; filename=somefilename.pdf'
"""
# Create the sheet.
# if render_type == 'sample':
# sheet = labels.Sheet(self.specs, self.draw_sample, border=self.debug)
sheet = labels.Sheet(self.specs, self.draw, border=self.debug)
# Add a couple of labels.
for i in range(int(labels_used)):
sheet.add_label({'blank':True})
for entry in objects:
sheet.add_label(entry)
# Save the file and we are done.
sheet.save(render_file)

127
config/settings.py Normal file
View File

@ -0,0 +1,127 @@
import os
import dj_database_url
from environs import Env
import json
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
with open(os.path.join(BASE_DIR,'secrets.json')) as f:
secrets = json.load(f)
TIME_ZONE = 'America/New_York'
SECRET_KEY = secrets.get('SECRET_KEY',"default_secret")
DEBUG = secrets.get('DEBUG', True)
SITE_ID = 1
ROOT_URLCONF = 'config.urls'
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'brewery.sqlite')
}
}
ALLOWED_HOSTS = secrets.get('ALLOWED_HOSTS', ['localhost', '127.0.0.1'])
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.admindocs',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'django.contrib.flatpages',
'mathfilters',
'yeast.apps.YeastLabConfig',
'beer.apps.BeerConfig',
'kegs.apps.KegConfig',
#'django.contrib.sites.apps.SitesConfig',
'django.contrib.humanize.apps.HumanizeConfig',
'django_nyt.apps.DjangoNytConfig',
'mptt',
'sekizai',
'sorl.thumbnail',
'wiki.apps.WikiConfig',
'wiki.plugins.attachments.apps.AttachmentsConfig',
'wiki.plugins.notifications.apps.NotificationsConfig',
'wiki.plugins.images.apps.ImagesConfig',
'wiki.plugins.macros.apps.MacrosConfig',
]
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
# 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
# 'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
"django.contrib.flatpages.middleware.FlatpageFallbackMiddleware",
# 'django.contrib.messages.middleware.MessageMiddleware',
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
# 'django.middleware.security.SecurityMiddleware',
]
MEDIA_ROOT = '/tmp/media/'
MEDIA_URL = '/media/'
STATIC_ROOT = '/tmp/static/'
STATIC_URL = '/static/'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR,'templates'),
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
# Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this
# list if you haven't customized them:
'django.contrib.auth.context_processors.auth',
'django.template.context_processors.debug',
'django.template.context_processors.i18n',
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.template.context_processors.request',
'django.contrib.messages.context_processors.messages',
'sekizai.context_processors.sekizai', # WIKI Stuff
],
},
},
]
WIKI_ACCOUNT_HANDLING = True
WIKI_ACCOUNT_SIGNUP_ALLOWED = True
DEFAULT_AUTO_FIELD='django.db.models.AutoField'
from django.urls import reverse_lazy
LOGIN_REDIRECT_URL = reverse_lazy('wiki:get', kwargs={'path': ''})
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {"class": "logging.StreamHandler"},
},
"loggers": {
"django": {
"handlers": ["console"],
"level": "INFO",
},
}
}
if DEBUG:
# make all loggers use the console.
for logger in LOGGING['loggers']:
LOGGING['loggers'][logger]['handlers'] = ['console']

21
config/urls.py Normal file
View File

@ -0,0 +1,21 @@
from django.urls import include, path
from django.contrib import admin
from django.contrib.flatpages import views
from .views import home
#from django.contrib.sites.models import Site
#admin.site.unregister(Site)
urlpatterns = [
path('admin/doc/', include('django.contrib.admindocs.urls')),
path('admin/', admin.site.urls),
path('about/', views.flatpage, {'url': '/about/'}, name='about'),
path("pages/", include('django.contrib.flatpages.urls')),
path('yeast/', include(('yeast.urls', 'yeast'))),
path('beer/', include(('beer.urls', 'beer'))),
path('kegs/', include(('kegs.urls', 'kegs'))),
path('notifications/', include('django_nyt.urls')),
path('wiki/', include('wiki.urls')),
path('', home, name="home"),
]

5
config/views.py Normal file
View File

@ -0,0 +1,5 @@
from django.shortcuts import render
def home(request):
return render(request, 'home.html',{})

0
kegs/__init__.py Normal file
View File

29
kegs/admin.py Normal file
View File

@ -0,0 +1,29 @@
from django.contrib import admin
from django.utils.html import format_html
from kegs.models import Keg, KegType, State
import logging
logger = logging.getLogger('django')
class KegInline(admin.TabularInline):
model = Keg
extra = 4
class KegAdmin(admin.ModelAdmin):
readonly_fields = ('id',)
list_display = ['id', 'kegstate', 'kegtype']
list_editable = ['kegstate', 'kegtype']
class KegTypeAdmin(admin.ModelAdmin):
list_display = ['name','manufacturer','size_gal',]
inlines = [
KegInline,
]
class StateAdmin(admin.ModelAdmin):
list_display = ['name']
admin.site.register(Keg, KegAdmin)
admin.site.register(KegType, KegTypeAdmin)
admin.site.register(State, StateAdmin)

6
kegs/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class KegConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'kegs'

View File

@ -0,0 +1,73 @@
# Generated by Django 5.0.6 on 2024-05-30 17:51
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='BatchTransactions',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='KegType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('name', models.CharField(max_length=100)),
('manufacturer', models.CharField(blank=True, max_length=100)),
('size_gal', models.DecimalField(decimal_places=2, max_digits=6)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='State',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('name', models.CharField(max_length=100)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='TransactionType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('name', models.CharField(max_length=100)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Keg',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('kegtype', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='kegs.kegtype')),
('kegstate', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='kegs.state')),
],
options={
'abstract': False,
},
),
]

View File

38
kegs/models.py Normal file
View File

@ -0,0 +1,38 @@
from django.db import models
from django.utils import timezone
from reportlab.graphics.barcode.qr import QrCodeWidget
import logging
logger = logging.getLogger('django')
class CustomModel(models.Model):
""" Custom model class with default fields to use. """
created_date = models.DateTimeField(default=timezone.now)
class Meta:
abstract = True
class State(CustomModel):
name = models.CharField(max_length=100)
def __str__(self):
return self.name
class KegType(CustomModel):
name = models.CharField(max_length=100)
manufacturer = models.CharField(max_length=100, blank=True)
size_gal = models.DecimalField(max_digits=6, decimal_places=2)
def __str__(self):
return self.name
class Keg(CustomModel):
kegtype = models.ForeignKey(KegType, on_delete=models.PROTECT)
kegstate = models.ForeignKey(State, on_delete=models.PROTECT)
class TransactionType(CustomModel):
name = models.CharField(max_length=100)
class BatchTransactions(CustomModel):
models.ForeignKey(Keg, on_delete=models.CASCADE)
models.ForeignKey(TransactionType, on_delete=models.PROTECT)

10
kegs/urls.py Normal file
View File

@ -0,0 +1,10 @@
from django.urls import include, path
from django.contrib import admin
from .views import keg_labels, home, KegListView, keg
urlpatterns = [
path('labels/', keg_labels, name='labels'),
path('<int:keg_id>/', keg, name='keg'),
path('', KegListView.as_view(), name='home'),
]

47
kegs/views.py Normal file
View File

@ -0,0 +1,47 @@
from django.shortcuts import render, get_object_or_404
from django.views.generic import ListView
from django.http import HttpResponse
from kegs.models import Keg
from config.extras import AveryLabel
import logging
logger = logging.getLogger('django')
def home(request):
return render(request, 'kegs/home.html',{'kegs':Keg.objects.all()})
class KegListView(ListView):
model = Keg
def keg(request, keg_id):
keg = get_object_or_404(Keg, pk=keg_id)
return render(request, 'kegs/keg.html', {'keg': keg})
def keg_labels(request):
""" Create label PDF for selected kegs
"""
skip_count = request.POST.get("skip_count", "")
kegs_list = request.POST.getlist("kegs", "")
to_print = list(filter(lambda d: str(d.id) in kegs_list, Keg.objects.all()))
# Create the HttpResponse object with the appropriate PDF headers.
response = HttpResponse(content_type ='application/pdf')
response['Content-Disposition'] = 'attachment; filename=keglabels.pdf'
labelSheet = AveryLabel(5263, debug=False)
labels = []
for keg in to_print:
labels.append({
'id': keg.id,
'title': keg.kegtype.name,
'data': ['{} gal'.format(keg.kegtype.size_gal), 'ID: {}'.format(keg.id)],
'blank': False,
'host': request.get_host(),
'template': 'keg',
'ns': 'kegs'
})
labelSheet.render(labels, response, skip_count)
return response

23
manage.py Normal file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
try:
from django.core.management import execute_from_command_line
except ImportError:
# The above import may fail for some other reason. Ensure that the
# issue is really that Django is missing to avoid masking other
# exceptions on Python 2.
try:
import django
except ImportError:
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?"
)
raise
execute_from_command_line(sys.argv)

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
Django==5.0.6
dj-database-url==2.1.0
psycopg2-binary==2.9.9
environs==11.0.0
docutils==0.21.2
django-mathfilters==1.0.0
pylabels==1.2.1
reportlab==4.2.0

3
run.sh Normal file
View File

@ -0,0 +1,3 @@
#! /usr/bin/env bash
python manage.py runserver 0.0.0.0:9595

74
templates/base.html Normal file
View File

@ -0,0 +1,74 @@
<html lang="en">
<head>
<title>{% block title %}{% endblock %}</title>
<!-- CSS only -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
<!-- JS, Popper.js, and jQuery -->
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js"
integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI"
crossorigin="anonymous"></script>
</head>
<body>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand" href="/">Damn Yankee Brewing</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsExampleDefault"
aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarsExampleDefault">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'yeast:batches' %}">Batches</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="dropdown01" data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false">Operations</a>
<div class="dropdown-menu" aria-labelledby="dropdown01">
<a class="dropdown-item" href="/yeast/">Yeast</a>
<a class="dropdown-item" href="/kegs/">Kegging</a>
<a class="dropdown-item" href="/beer/">Beer</a>
</div>
</li>
<li class="nav-item">
<a class="nav-link" href="/wiki/">Wiki</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/about/">About</a>
</li>
</ul>
<!-- <form class="form-inline my-2 my-lg-0">
<input class="form-control mr-sm-2" type="text" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
</form> -->
</div>
</nav>
<div>
{% block content %}
{% endblock %}
</div>
<footer class="text-left fixed-bottom">
<!-- Copyright -->
<div class="text-center p-1" style="background-color: rgba(0, 0, 0, 0.1);">
&copy; Damn yankee Brewing 2010-{% now "Y" %}
</div>
<!-- Copyright -->
</footer>
</body>
</html>

View File

@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% block title %}{{ flatpage.title }}{% endblock %}
{% block content %}
<main role="main">
<!-- Main jumbotron for a primary marketing message or call to action -->
<div class="jumbotron">
<div class="container">
<h1 class="display-3">{{ flatpage.title }}</h1>
</div>
</div>
<div class="container">
{{ flatpage.content }}
</div> <!-- /container -->
</main>
{% endblock %}

43
templates/home.html Normal file
View File

@ -0,0 +1,43 @@
{% extends 'base.html' %}
{% block title %} Home {% endblock %}
{% block content %}
<main role="main">
<!-- Main jumbotron for a primary marketing message or call to action -->
<div class="jumbotron">
<div class="container">
<h1 class="display-3">Brewing Tools</h1>
</div>
</div>
<div class="container">
<!-- Example row of columns -->
<div class="row">
<div class="col-md-4">
<h2>Yeast Lab</h2>
<p>All that fun sciency stuff. </p>
<p><a class="btn btn-secondary" href="/yeast/" role="button">Go &raquo;</a></p>
</div>
<div class="col-md-4">
<h2>Kegs</h2>
<p>Maintenance stuff. </p>
<p><a class="btn btn-secondary" href="/kegs/" role="button">Go &raquo;</a></p>
</div>
<div class="col-md-4">
<h2>Recipes</h2>
<p>My own personal Brewfather?</p>
<p><a class="btn btn-secondary" href="/beer/" role="button">Go &raquo;</a></p>
</div>
</div>
<hr>
</div> <!-- /container -->
</main>
{% endblock %}

31
templates/kegs/keg.html Normal file
View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% load mathfilters %}
{% block title %}Keg Information{% endblock %}
{% block content %}
<main role="main">
<!-- Main jumbotron for a primary marketing message or call to action -->
<div class="jumbotron">
<div class="container">
<h1 class="display-3">Keg: {{ keg.id }}</h1>
</div>
</div>
<div class="container">
<h3>Attributes</h3>
<ul>
<li>Type: {{ keg.kegtype.name }}</li>
<li>Size: {{ keg.kegtype.size_gal }} Gallons</li>
<li>Status: {{ keg.kegstate.name }}</li>
</ul>
</div> <!-- /container -->
</main>
{% endblock %}

View File

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% load mathfilters %}
{% block title %}Kegging Dashboard{% endblock %}
{% block content %}
<main role="main">
<!-- Main jumbotron for a primary marketing message or call to action -->
<div class="jumbotron">
<div class="container">
<h1 class="display-3">Kegging Dashboard</h1>
</div>
</div>
<div class="container">
</div> <!-- /container -->
<div class="container">
<form action="{% url 'kegs:labels' %}" method="post">
<fieldset>
<legend>Existing Kegs</legend>
<ul>
{% for keg in object_list %}
<li>
<label for="{{ keg.id }}">
<input type="checkbox" id="{{ keg.id }}" name="kegs" value="{{ keg.id }}"/>
<a href="{% url 'kegs:keg' keg.id %}">{{ keg.kegtype.name }} ID: {{ keg.id }}</a> Currently: {{ keg.kegstate.name }}
</label>
</li>
{% endfor %}
</ul>
</fieldset>
<p><p>
<h1>Print Labels for selected kegs</h1>
<p><p>
<label for="skip_count">Number of labels already removed from the sheet: </label>
<input id="skip_count" type="number" name="skip_count" value=0 size="4">
<p>
<input type="submit" value="Print Labels">
</form>
</div> <!-- /container -->
</main>
{% endblock %}

View File

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% load mathfilters %}
{% block title %}Sample Batch{% endblock %}
{% block content %}
<main role="main">
<!-- Main jumbotron for a primary marketing message or call to action -->
<div class="jumbotron">
<div class="container">
<h1 class="display-3">Batch Number: {{ batch.id }}</h1>
</div>
</div>
<div class="container">
<h1>{{ batch.strain.name }}</h1>
<b>Source: {{ batch.get_source_display }}</b>
<p><p>
</div> <!-- /container -->
{% url 'yeast:labels' batch.id %}
<div class="container">
<form action="{% url 'yeast:labels' batch.id %}" method="post">
<fieldset>
<legend>Batch Samples</legend>
<ul>
{% for sample in batch.yeast_set.all %}
<li>
<label for="{{ sample.id }}">
{% if sample.pitched %}
<input type="checkbox" id="{{ sample.id }}" name="samples" value="{{ sample.id }}"/>
<strike>
<a href="{% url 'yeast:yeast' sample.id %}">{{ sample.id }}: Sample no longer available</a>
</strike>
{% else %}
<input type="checkbox" id="{{ sample.id }}" name="samples" value="{{ sample.id }}" checked/>
<a href="{% url 'yeast:yeast' sample.id %}">{{ sample.id }}: {{ sample.packaging_date }}</a> {{ sample.age }} days old with estimated {{ sample.viability|mul:100|floatformat:1 }}% viability
{% endif %}
</label>
</li>
{% endfor %}
</ul>
</fieldset>
<p><p>
<h1>Print Labels for this Batch</h1>
<p><p>
<label for="skip_count">Number of labels already removed from the sheet: </label>
<input id="skip_count" type="number" name="skip_count" value=0 size="4">
<p>
<input type="submit" value="Print selected sample labels">
</form>
</div> <!-- /container -->
</main>
{% endblock %}

View File

@ -0,0 +1,23 @@
<h1>Add New Batch</h1>
<form action="" method="post">{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Submit" />
</form>
<!-- <form action="addbatch/" method="post">
{% csrf_token %}
production_date:<br>
<input name="production_date">
<br><br>
strain:<br>
<input name="strain">
<br><br>
source:<br>
<input name="source">
<br><br>
data_web:<br>
<input name="data_web">
<br><br>
<input type="submit" value="Submit">
</form> -->

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% load mathfilters %}
{% block title %}Sample Batches{% endblock %}
{% block content %}
<main role="main">
<!-- Main jumbotron for a primary marketing message or call to action -->
<div class="jumbotron">
<div class="container">
<h1 class="display-3">Yeast Sample Batches</h1>
</div>
</div>
<div class="container">
<ul>
{% for batch in object_list %}
{% if batch.remaining_samples %}
<li><a href="{% url 'yeast:batch' batch.id %}">{{ batch }}</a></li>
<ul>
{% for sample in batch.yeast_set.all %}
{% if sample.pitched %}<strike>{% endif %}
<li><a href="{% url 'yeast:yeast' sample.id %}">Sample #{{ sample.id }}</a> Age: {{ sample.age }} days, Viability: {{ sample.viability|mul:100|floatformat:1 }}%</li>
{% if sample.pitched %}</strike>{% endif %}
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</ul>
<p><p>
Test
<a href="{% url 'yeast:addbatch' %}">Add Batch</a>
<a href="{% url 'yeast:addstrain' %}">Add Yeast Strain</a>
</div> <!-- /container -->
</main>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% load mathfilters %}
{% block title %}Yeast Samples{% endblock %}
{% block content %}
<main role="main">
<!-- Main jumbotron for a primary marketing message or call to action -->
<div class="jumbotron">
<div class="container">
<h1 class="display-3">Yeast Sample: {{ sample.id }}</h1>
</div>
</div>
<div class="container">
<h3>{{ batch.strain.name }}</h3>
Batch Source: {{ batch.source }}
</div> <!-- /container -->
{% for sample in batch.remaining_samples %}
<ul>
<li><a href="/samples/{{ sample.id }}">{{ sample.packaging_date }}</a> Strain: {{ sample.age }} days, Viability:{{ sample.viability|mul:100|floatformat:1 }}%</li>
</ul>
{% endfor %}
</main>
{% endblock %}

View File

@ -0,0 +1,23 @@
<h1>Add New Strain</h1>
<form action="" method="post">{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Submit" />
</form>
<!-- <form action="addbatch/" method="post">
{% csrf_token %}
production_date:<br>
<input name="production_date">
<br><br>
strain:<br>
<input name="strain">
<br><br>
source:<br>
<input name="source">
<br><br>
data_web:<br>
<input name="data_web">
<br><br>
<input type="submit" value="Submit">
</form> -->

View File

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Yeast Samples{% endblock %}
{% block content %}
<main role="main">
<!-- Main jumbotron for a primary marketing message or call to action -->
<div class="jumbotron">
<div class="container">
<h1 class="display-3">Yeast Samples</h1>
</div>
</div>
<div class="container">
<ul>
{% for sample in object_list %}
<li>{{ sample.batch }}</li>
{% endfor %}
</ul>
</div> <!-- /container -->
</main>
{% endblock %}

View File

@ -0,0 +1,73 @@
# Generated by Django 5.0.6 on 2024-05-23 13:26
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
#replaces = [('yeast', '0001_initial'), ('yeast', '0002_remove_yeast_strain_batch_source'), ('yeast', '0003_alter_yeast_parent_id'), ('yeast', '0004_alter_yeast_parent_id'), ('yeast', '0005_alter_yeast_parent_id'), ('yeast', '0006_remove_yeast_parent_id_yeast_parent'), ('yeast', '0007_batch_created_date_manufacturer_created_date_and_more'), ('yeast', '0008_yeast_notes'), ('yeast', '0009_manufacturer_website_yeast_data_web_and_more'), ('yeast', '0010_alter_manufacturer_website_alter_yeast_data_web'), ('yeast', '0011_remove_yeast_data_web_batch_data_web_and_more')]
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Manufacturer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('website', models.URLField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='Storage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('viability_loss', models.DecimalField(decimal_places=4, max_digits=6)),
('viability_interval', models.IntegerField(default=30)),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
],
),
migrations.CreateModel(
name='Strain',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('manufacturer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yeast.manufacturer')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
],
),
migrations.CreateModel(
name='Yeast',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('generation_num', models.IntegerField(default=0)),
('cellcount', models.IntegerField(default=100)),
('pitched', models.BooleanField(default=False)),
('purchase_date', models.DateField(default=django.utils.timezone.now)),
('packaging_date', models.DateField(default=django.utils.timezone.now)),
('storage', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yeast.storage')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='yeast.yeast')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('notes', models.CharField(blank=True, max_length=500, null=True)),
],
),
migrations.CreateModel(
name='Batch',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('production_date', models.DateField()),
('strain', models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='yeast.strain')),
('sample', models.ManyToManyField(to='yeast.yeast')),
('source', models.CharField(choices=[('ST', 'Purchased'), ('PR', 'Propogated'), ('SL', 'Fermenter Slurry')], default='ST', max_length=3)),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('data_web', models.URLField(blank=True, null=True)),
],
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-05-23 13:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0001_squashed_0011_remove_yeast_data_web_batch_data_web_and_more'),
]
operations = [
migrations.RemoveField(
model_name='batch',
name='sample',
),
migrations.AddField(
model_name='yeast',
name='batch',
field=models.ForeignKey(default=2, on_delete=django.db.models.deletion.CASCADE, to='yeast.batch'),
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.0.6 on 2024-05-23 15:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0012_remove_batch_sample_yeast_batch'),
]
operations = [
migrations.AlterField(
model_name='yeast',
name='batch',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yeast.batch'),
),
migrations.RemoveField(
model_name='yeast',
name='parent',
),
migrations.AddField(
model_name='yeast',
name='parent',
field=models.ManyToManyField(to='yeast.yeast'),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.0.6 on 2024-05-23 15:35
import django.db.models.manager
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0013_alter_yeast_batch_remove_yeast_parent_yeast_parent'),
]
operations = [
migrations.AlterModelManagers(
name='yeast',
managers=[
('avail', django.db.models.manager.Manager()),
],
),
migrations.AlterField(
model_name='yeast',
name='parent',
field=models.ManyToManyField(blank=True, to='yeast.yeast'),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 5.0.6 on 2024-05-29 17:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0014_alter_yeast_managers_alter_yeast_parent'),
]
operations = [
migrations.AlterModelManagers(
name='yeast',
managers=[
],
),
migrations.RemoveField(
model_name='yeast',
name='parent',
),
migrations.AddField(
model_name='batch',
name='parent',
field=models.ManyToManyField(blank=True, related_name='+', to='yeast.yeast'),
),
migrations.AlterField(
model_name='batch',
name='source',
field=models.CharField(choices=[('ST', 'Store'), ('PR', 'Propogated'), ('SL', 'Slurry')], default='ST', max_length=3),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.0.6 on 2024-05-29 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0015_alter_yeast_managers_remove_yeast_parent_and_more'),
]
operations = [
migrations.RemoveField(
model_name='batch',
name='data_web',
),
migrations.AddField(
model_name='yeast',
name='data_web',
field=models.URLField(blank=True, null=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.0.6 on 2024-05-29 17:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('yeast', '0016_remove_batch_data_web_yeast_data_web'),
]
operations = [
migrations.RemoveField(
model_name='yeast',
name='packaging_date',
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.0.6 on 2024-05-29 17:42
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('yeast', '0017_remove_yeast_packaging_date'),
]
operations = [
migrations.RemoveField(
model_name='yeast',
name='purchase_date',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-05-29 17:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0018_remove_yeast_purchase_date'),
]
operations = [
migrations.AddField(
model_name='yeast',
name='lot_number',
field=models.CharField(blank=True, max_length=30, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-05-29 17:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0019_yeast_lot_number'),
]
operations = [
migrations.AddField(
model_name='yeast',
name='date_pitched',
field=models.DateField(blank=True, null=True),
),
migrations.AlterField(
model_name='yeast',
name='lot_number',
field=models.CharField(blank=True, max_length=15, null=True),
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 5.0.6 on 2024-05-29 18:02
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0020_yeast_date_pitched_alter_yeast_lot_number'),
]
operations = [
migrations.CreateModel(
name='BeerBatch',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('brewfather_id', models.CharField(max_length=50)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='yeast',
name='pitched_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='yeast.beerbatch'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.0.6 on 2024-05-29 18:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0021_beerbatch_yeast_pitched_batch'),
]
operations = [
migrations.AddField(
model_name='batch',
name='source_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='yeast.beerbatch'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-05-29 19:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0022_batch_source_batch'),
]
operations = [
migrations.AddField(
model_name='beerbatch',
name='brewfather_name',
field=models.CharField(default='name', max_length=500),
),
migrations.AddField(
model_name='beerbatch',
name='brewfather_num',
field=models.IntegerField(default=1),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-05-29 20:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0023_beerbatch_brewfather_name_beerbatch_brewfather_num'),
]
operations = [
migrations.AddField(
model_name='strain',
name='long_name',
field=models.CharField(blank=True, max_length=100),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 5.0.6 on 2024-05-30 12:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0001_initial'),
('yeast', '0024_strain_long_name'),
]
operations = [
migrations.AlterField(
model_name='yeast',
name='pitched_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='beer.batch'),
),
migrations.AlterField(
model_name='batch',
name='source_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='beer.batch'),
),
migrations.AlterField(
model_name='yeast',
name='notes',
field=models.TextField(blank=True, max_length=500, null=True),
),
migrations.DeleteModel(
name='BeerBatch',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-05-30 13:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yeast', '0025_alter_yeast_pitched_batch_alter_batch_source_batch_and_more'),
]
operations = [
migrations.AddField(
model_name='batch',
name='notes',
field=models.TextField(blank=True, max_length=500, null=True),
),
]

0
yeast/__init__.py Normal file
View File

128
yeast/admin.py Normal file
View File

@ -0,0 +1,128 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from yeast.models import Yeast, Strain, Manufacturer, Storage, Batch
from yeast.forms import YeastModelForm
import beer
from config.extras import BREWFATHER_APP_ROOT
import logging
logger = logging.getLogger('django')
class BatchInline(admin.TabularInline):
model = Batch
extra = 0
class SampleInline(admin.TabularInline):
model = Yeast
extra = 0
class StrainInline(admin.TabularInline):
model = Strain
extra = 5
class ParentInline(admin.TabularInline):
verbose_name = 'Parent Samples'
model = Batch.parent.through
class YeastAdmin(admin.ModelAdmin):
list_display = [ 'batch', 'url', 'lot_number', 'age', 'storage', 'viability', 'generation_num', 'cellcount', 'pitched', 'date_pitched', 'pitched_batch']
list_editable = ['pitched', 'date_pitched', 'pitched_batch', 'lot_number']
def batch_url(self, obj):
if obj.pitched_batch:
bf_id = obj.pitched_batch.brewfather_id
return format_html("<a href='https://web.brewfather.app/tabs/batches/batch/{batch_id}'>{batch_id}</a>", batch_id=bf_id)
def url(self, obj):
if obj.data_web:
return format_html("<a href='{url}'>{url}</a>", url=obj.data_web)
class StrainAdmin(admin.ModelAdmin):
list_display = ['name', 'long_name', 'manufacturer', 'avilable_batches']
inlines = [
BatchInline,
]
list_editable = ['long_name', 'manufacturer']
def avilable_batches(self, obj):
related_objs = [x for x in obj.batch_set.all() if not x.consumed]
urls = []
for related_obj in related_objs:
url_text = '{}: {}'.format(related_obj.get_source_display(), related_obj.production_date.strftime("%Y-%m-%d"))
url = reverse('admin:yeast_batch_change', args=[related_obj.id]) # replace 'myapp' with your app name
urls.append('<a href="{}">{}</a>'.format(url, url_text))
return format_html(', '.join(urls))
avilable_batches.short_description = 'Available Batches'
class StorageAdmin(admin.ModelAdmin):
list_display = ['name', 'viability_loss', 'viability_interval']
inlines = [
SampleInline,
]
class ManufacturerAdmin(admin.ModelAdmin):
list_display = ['name', 'url']
inlines = [
StrainInline,
]
def url(self, obj):
if obj.website:
return format_html("<a href='{url}'>{url}</a>", url=obj.website)
class BatchAdmin(admin.ModelAdmin):
list_display = ['strain', 'consumed', 'source', 'parent_samples', 'production_date', 'avilable_samples', 'used_samples']
form = YeastModelForm
filter_horizontal = ['parent']
inlines = [
#ParentInline,
SampleInline,
]
def save_related(self, request, form, formsets, change):
super(BatchAdmin, self).save_related(request, form, formsets, change)
if form.instance.source_batch:
relate_samples = [x for x in Yeast.objects.all() if x.pitched_batch==form.instance.source_batch]
for sample in relate_samples:
logger.critical(sample)
form.instance.parent.add(sample)
def parent_samples(self, obj):
obj.parent.all()
def used_samples(self, obj):
related_objs = list(set(obj.yeast_set.all()) - set(obj.remaining_samples))
urls = []
for related_obj in related_objs:
url = reverse('admin:yeast_yeast_change', args=[related_obj.id])
urls.append('<a href="{}">{}</a>'.format(url, related_obj))
return format_html(', '.join(urls))
used_samples.short_description = 'Used Samples'
def avilable_samples(self, obj):
related_objs = obj.remaining_samples
urls = []
for related_obj in related_objs:
url = reverse('admin:yeast_yeast_change', args=[related_obj.id]) # replace 'myapp' with your app name
urls.append('<a href="{}">{}</a>'.format(url, related_obj))
return format_html(', '.join(urls))
avilable_samples.short_description = 'Available Samples'
admin.site.register(Yeast, YeastAdmin)
admin.site.register(Strain, StrainAdmin)
admin.site.register(Manufacturer, ManufacturerAdmin)
admin.site.register(Storage, StorageAdmin)
admin.site.register(Batch, BatchAdmin)

6
yeast/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class YeastLabConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'yeast'

60
yeast/forms.py Normal file
View File

@ -0,0 +1,60 @@
from django import forms
from django.urls import reverse
from django.http import HttpResponse, HttpResponseRedirect
from .models import Yeast, Batch, Strain
import logging
logger = logging.getLogger('django')
class DateInput(forms.DateInput):
input_type = 'date'
class YeastModelForm(forms.ModelForm):
class Meta:
model = Yeast
fields = '__all__'
def __init__(self, *args, **kwargs):
forms.ModelForm.__init__(self, *args, **kwargs)
self.fields['parent'].queryset = Yeast.objects.all()
# creating a form
class BatchAddForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(BatchAddForm, self).__init__(*args, **kwargs)
self.fields['strain'].help_text = '<a href="{}">Add a new strain</a>'.format(reverse('yeast:addstrain'))
# create meta class
class Meta:
# specify model to be used
model = Batch
# specify fields to be used
fields = [
'production_date',
'strain',
'source',
]
widgets = {
'production_date': DateInput(),
}
num_samples = forms.IntegerField()
class StrainAddForm(forms.ModelForm):
# create meta class
class Meta:
# specify model to be used
model = Strain
# specify fields to be used
fields = [
'name',
'long_name',
'manufacturer',
]

View File

@ -0,0 +1,100 @@
# Generated by Django 5.0.6 on 2024-05-30 13:56
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
replaces = [('yeast', '0001_squashed_0011_remove_yeast_data_web_batch_data_web_and_more'), ('yeast', '0012_remove_batch_sample_yeast_batch'), ('yeast', '0013_alter_yeast_batch_remove_yeast_parent_yeast_parent'), ('yeast', '0014_alter_yeast_managers_alter_yeast_parent'), ('yeast', '0015_alter_yeast_managers_remove_yeast_parent_and_more'), ('yeast', '0016_remove_batch_data_web_yeast_data_web'), ('yeast', '0017_remove_yeast_packaging_date'), ('yeast', '0018_remove_yeast_purchase_date'), ('yeast', '0019_yeast_lot_number'), ('yeast', '0020_yeast_date_pitched_alter_yeast_lot_number'), ('yeast', '0021_beerbatch_yeast_pitched_batch'), ('yeast', '0022_batch_source_batch'), ('yeast', '0023_beerbatch_brewfather_name_beerbatch_brewfather_num'), ('yeast', '0024_strain_long_name'), ('yeast', '0025_alter_yeast_pitched_batch_alter_batch_source_batch_and_more'), ('yeast', '0026_batch_notes')]
initial = True
dependencies = [
('beer', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Manufacturer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('website', models.URLField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='Storage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('viability_loss', models.DecimalField(decimal_places=4, max_digits=6)),
('viability_interval', models.IntegerField(default=30)),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
],
),
migrations.CreateModel(
name='Strain',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('manufacturer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yeast.manufacturer')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('long_name', models.CharField(blank=True, max_length=100)),
],
),
migrations.CreateModel(
name='Batch',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('production_date', models.DateField()),
('strain', models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='yeast.strain')),
('source', models.CharField(choices=[('ST', 'Purchased'), ('PR', 'Propogated'), ('SL', 'Fermenter Slurry')], default='ST', max_length=3)),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('data_web', models.URLField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='Yeast',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('generation_num', models.IntegerField(default=0)),
('cellcount', models.IntegerField(default=100)),
('pitched', models.BooleanField(default=False)),
('storage', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yeast.storage')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('notes', models.TextField(blank=True, max_length=500, null=True)),
('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yeast.batch')),
('data_web', models.URLField(blank=True, null=True)),
('lot_number', models.CharField(blank=True, max_length=15, null=True)),
('date_pitched', models.DateField(blank=True, null=True)),
('pitched_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='beer.batch')),
],
),
migrations.AddField(
model_name='batch',
name='parent',
field=models.ManyToManyField(blank=True, related_name='+', to='yeast.yeast'),
),
migrations.AlterField(
model_name='batch',
name='source',
field=models.CharField(choices=[('ST', 'Store'), ('PR', 'Propogated'), ('SL', 'Slurry')], default='ST', max_length=3),
),
migrations.RemoveField(
model_name='batch',
name='data_web',
),
migrations.AddField(
model_name='batch',
name='source_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='beer.batch'),
),
migrations.AddField(
model_name='batch',
name='notes',
field=models.TextField(blank=True, max_length=500, null=True),
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 5.0.6 on 2024-05-30 17:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0003_batch_recipe'),
('yeast', '0001_squashed_0026_batch_notes'),
]
operations = [
migrations.AlterField(
model_name='batch',
name='source_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='beer.batch'),
),
migrations.AlterField(
model_name='batch',
name='strain',
field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.PROTECT, to='yeast.strain'),
),
migrations.AlterField(
model_name='strain',
name='manufacturer',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='yeast.manufacturer'),
),
]

View File

152
yeast/models.py Normal file
View File

@ -0,0 +1,152 @@
from django.db import models
from django.utils import timezone
from django.utils.html import format_html
from datetime import datetime
from django.contrib.auth.models import User
from django.db.models import Count
import math
import logging
logger = logging.getLogger('django')
class AvailableYeastManager(models.Manager):
""" Special manager for filtering out pitched yeast."""
def get_queryset(self):
return super(AvailableYeastManager, self).get_queryset().filter(pitched=False)
class CustomModel(models.Model):
""" Custom model class with default fields to use. """
created_date = models.DateTimeField(default=timezone.now)
class Meta:
abstract = True
class Manufacturer(CustomModel):
""" Store manufacturer data for various yeast strains."""
name = models.CharField(max_length=100)
website = models.URLField(max_length=200, blank=True, null=True)
def __str__(self):
# Return a string that represents the instance
return self.name
class Strain(CustomModel):
"""
Store individual yeast strain data. :model:`yeast.Manufacturer`.
"""
name = models.CharField(max_length=100)
long_name = models.CharField(max_length=100, blank=True)
manufacturer = models.ForeignKey(Manufacturer, on_delete=models.PROTECT)
def __str__(self):
# Return a string that represents the instance
return '{}: {}'.format(self.manufacturer.name, self.name)
class Storage(CustomModel):
"""
Data for methods of yeast storage. Used for calculating viability
in :model:`yeast.Yeast`.
"""
name = models.CharField(max_length=100)
viability_loss = models.DecimalField(max_digits=6, decimal_places=4)
viability_interval = models.IntegerField(default=30)
def __str__(self):
# Return a string that represents the instance
return self.name
class Batch(CustomModel):
"""
Stores a batch of :model:`yeast.Yeast` of a single :model:`yeast.Strain`.
Can be a single purchased pack, or multiple vials
to be frozen from a starter.
"""
BATCH_TYPES = {
'ST': 'Store',
'PR': 'Propogated',
'SL': 'Slurry',
}
parent = models.ManyToManyField('Yeast', related_name='+', blank=True)
production_date = models.DateField()
strain = models.ForeignKey(Strain, on_delete=models.PROTECT, default=0)
source = models.CharField(max_length=3, choices=BATCH_TYPES, default='ST')
source_batch = models.ForeignKey('beer.Batch', null=True, blank=True, on_delete=models.PROTECT)
notes = models.TextField(max_length=500, blank=True, null=True)
def save(self, *args, **kwargs):
super(Batch, self).save(*args, **kwargs)
if self.source_batch:
relate_samples = [x for x in Yeast.objects.all() if x.pitched_batch==self.source_batch]
for sample in relate_samples:
logger.critical(sample)
self.parent.add(sample)
@property
def consumed(self):
return not len(self.remaining_samples) > 0
@property
def remaining_samples(self):
return [x for x in Yeast.available.all() if x.batch==self]
def __str__(self):
# Return a string that represents the instance
return '{} [{}]'.format(self.strain, self.production_date.strftime("%Y-%m-%d"))
class Yeast(CustomModel):
"""
Store an individual sample of yeast.
"""
batch = models.ForeignKey(Batch, on_delete=models.CASCADE)
generation_num = models.IntegerField(default=0)
storage = models.ForeignKey(Storage, on_delete=models.CASCADE)
cellcount = models.IntegerField(default=100)
pitched = models.BooleanField(default=False)
date_pitched = models.DateField(blank=True, null=True)
pitched_batch = models.ForeignKey('beer.Batch', null=True, blank=True, on_delete=models.CASCADE)
data_web = models.URLField(blank=True, null=True)
lot_number = models.CharField(max_length=15, blank=True, null=True)
notes = models.TextField(max_length=500, blank=True, null=True)
objects = models.Manager()
available = AvailableYeastManager()
@property
def name(self):
return '{} {}'.format(self.id, self.batch.strain.name)
@property
def age(self):
"""Return the age in days since the sample was propogated."""
if self.pitched:
end_date = self.date_pitched
else:
end_date = timezone.now().date()
return abs((self.batch.production_date-end_date).days)
@property
def viability(self):
"""Return the viability based on age and storage method (:model:`yeast.Storage`)."""
return 0.97 * math.exp(self.age * math.log(1-self.storage.viability_loss)/self.storage.viability_interval)
def __str__(self):
# Return a string that represents the instance
return '{} {}'.format(self.id, self.batch.strain.name)
# class BeerBatch(CustomModel):
# brewfather_id = models.CharField(max_length=50)
# brewfather_num = models.IntegerField(default=1)
# brewfather_name = models.CharField(max_length=500, default='name')
# def __str__(self):
# # Return a string that represents the instance
# return 'BF #{num}: {name}'.format(name=self.brewfather_name, num=self.brewfather_num)

3
yeast/tests.py Normal file
View File

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

18
yeast/urls.py Normal file
View File

@ -0,0 +1,18 @@
from django.urls import include, path
from django.contrib import admin
from django.contrib.flatpages import views
from yeast.views import YeastListView, BatchListView, home, sample, batch, batch_labels, addBatch, addStrain
# app_name = 'yeast'
urlpatterns = [
path('samples/<int:yeast_id>/', sample, name='yeast'),
path('samples/', YeastListView.as_view(), name='yeasts'),
path('batches/addbatch/', addBatch.as_view(), name='addbatch'),
path('batches/addstrain/', addStrain.as_view(), name='addstrain'),
path('batches/<int:batch_id>/', batch, name='batch'),
path('batches/', BatchListView.as_view(), name='batches'),
path('batch_labels/<int:batch_id>/', batch_labels, name='labels'),
]

89
yeast/views.py Normal file
View File

@ -0,0 +1,89 @@
from django.shortcuts import render, get_object_or_404
from django.views.generic import ListView
from django.views.generic.edit import CreateView
from django.http import HttpResponse, HttpRequest
from django.urls import reverse
from yeast.models import Yeast, Batch, Strain
from config.extras import AveryLabel
from yeast.forms import BatchAddForm, StrainAddForm
import logging
logger = logging.getLogger('django')
class YeastListView(ListView):
model = Yeast
class BatchListView(ListView):
model = Batch
def sample(request, sample_id):
sample = get_object_or_404(Yeast, pk=sample_id)
sample_batch = get_object_or_404(Batch, pk=sample.batch_id)
return render(request, 'yeast/sample.html', {'sample': sample, 'batch':sample_batch})
def home(request):
return render(request, 'home.html',{})
def batch(request, batch_id):
batch = get_object_or_404(Batch, pk=batch_id)
return render(request, 'yeast/batch.html', {'batch': batch})
def batch_labels(request, batch_id):
""" Create label PDF for samples in a batch
"""
skip_count = request.POST.get("skip_count", "")
samples = request.POST.getlist("samples", "")
batch = get_object_or_404(Batch, pk=batch_id)
to_print = list(filter(lambda d: str(d.id) in samples, batch.yeast_set.all()))
# Create the HttpResponse object with the appropriate PDF headers.
response = HttpResponse(content_type ='application/pdf')
response['Content-Disposition'] = 'attachment; filename=samplelabels.pdf'
labelSheet = AveryLabel(18294, debug=False)
logger.critical(samples)
logger.critical(to_print)
labels = []
for sample in to_print:
# labels.append({
# 'date': sample.batch.production_date,
# 'name': sample.name,
# 'manufacturer': sample.batch.strain.manufacturer.name,
# 'id': sample.id,
# 'blank': False,
# 'host': request.get_host(),
# })
labels.append({
'id': sample.id,
'title': '{} {}'.format(sample.batch.strain.manufacturer.name, sample.name),
'data': ['ID: {}'.format(sample.id), 'Date: {}'.format(sample.batch.production_date)],
'blank': False,
'host': request.get_host(),
'template': 'yeast',
'ns': 'yeast'
})
labelSheet.render(labels, response, skip_count)
return response
class addBatch(CreateView):
model = Batch
form_class = BatchAddForm
def get_success_url(self):
id = self.object.id #gets id from created object
return reverse('yeast:batches', kwargs={'batch_id': id})
class addStrain(CreateView):
model = Strain
form_class = StrainAddForm
def get_success_url(self):
id = self.object.id #gets id from created object
return reverse('yeast:batches')

16
yeast/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for django_sqlite 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/1.10/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_sqlite.settings")
application = get_wsgi_application()