Compare commits

85 Commits
Author SHA1 Message Date
ponas 284e5bd4a0 feat(pxe): send uefi clients the efi loader
CI / deploy-sega (push) Successful in 10s
CI / prek (push) Successful in 11s
CI / tests (push) Successful in 42s
2026-11-04 22:15:23 +01:00
ponas 8f7e34a762 feat(pxe): one menu for every installable host, no per-host pxelinux configs
CI / tests (push) Successful in 45s
CI / prek (push) Successful in 12s
CI / deploy-sega (push) Successful in 12s
2026-11-04 21:37:14 +01:00
ponas ff27b75101 fix(zone_sync): thaw and warn when a sync is stopped or killed
CI / prek (push) Successful in 15s
CI / tests (push) Successful in 41s
CI / deploy-sega (push) Successful in 15s
2026-11-02 01:47:37 +01:00
ponas 571523354e fix(zone_sync): replace zone files in one step 2026-11-02 01:47:37 +01:00
ponas bc46a33201 docs: add AGENTS.md 2026-11-02 01:31:04 +01:00
ponas 782c9465bf chore: install prek with mise 2026-11-02 01:31:04 +01:00
ponas 0e38b91fc5 test: cover the generated configs, the PXE API and PXE files 2026-11-02 01:31:04 +01:00
ponas ad42f86ed7 fix(admin): name the fields for select_related 2026-11-02 01:31:04 +01:00
ponas f0ecdb41bc fix(pxe): pad IP-based PXE filenames to 8 hex digits 2026-11-02 01:31:03 +01:00
ponas a8a4c76d98 fix(forms): stop InterfaceForm crashing on invalid or IP-less interfaces 2026-11-02 01:31:03 +01:00
ponas 3b589f6085 fix: never lower a serial or cap it at 99 2026-11-02 01:31:03 +01:00
ponas 58937e1105 fix(discord): time out webhook requests 2026-11-02 01:31:03 +01:00
ponas d43aa43186 fix: name the user with getpass in sync failure alerts, which cron broke 2026-11-02 01:31:03 +01:00
ponas cb2784eec5 fix(settings): read SENTRY_ENVIRONMENT, drop settings Django and DRF ignore 2026-11-02 01:31:03 +01:00
ponas d7c97df832 fix(settings): add SecurityMiddleware and XFrameOptionsMiddleware 2026-11-02 01:31:03 +01:00
ponas d535e1b080 build: bump dependencies, pin Python 3.14, drop the ipaddress backport 2026-11-02 01:30:52 +01:00
ponas 284dfdbdf4 ci: one workflow for tests, prek and the deploy to sega
CI / prek (push) Successful in 16s
CI / tests (push) Successful in 30s
CI / deploy-sega (push) Successful in 9s
2026-11-02 01:17:10 +01:00
ponas d3b6024cd8 ci: prek, and deploy to sega from main
deploy / checks (push) Skipped
prek / prek (push) Successful in 16s
checks / checks (push) Successful in 33s
deploy / prek (push) Skipped
deploy / deploy-sega (push) Skipped
2026-11-02 01:05:58 +01:00
ponas 3ccce3849e feat: production settings from MDB_SETTINGS, DEBUG off, whitenoise 2026-11-02 01:05:58 +01:00
ponas be0e621b28 style: apply ruff and django-upgrade 2026-11-02 01:05:51 +01:00
ponas 1e128e8d9b chore: remove the Python 2 scripts and the fabric deploy 2026-11-02 01:05:38 +01:00
ponas 1d0ba86d53 test(zone_sync): expect BIND 9.18 compilezone output to match the DNS server
checks / checks (push) Successful in 37s
2026-09-27 17:34:33 +02:00
ponas fe62d13d3c ci: add Gitea checks workflow running the test suite
checks / checks (push) Failing after 37s
2026-09-27 17:25:09 +02:00
ponas d2155bc065 chore(deps): drop mysqlclient and move django-auth-ldap to a prod group 2026-09-27 17:25:09 +02:00
ponas 478871838a fix(zone_sync): freeze before writing and keep serials above dynamic updates 2026-09-27 17:21:00 +02:00
ponas 16f76a9575 Show allow_email column for hosts and allow filtering 2026-06-10 00:50:56 +02:00
ponas 9e065f37c2 Add button for bumping serial 2026-06-10 00:40:17 +02:00
ponas 5c275dd1e7 Add RFC 2317 classless reverse zone names for sub-/24 subnets 2026-06-10 00:31:49 +02:00
ponas 47acd88923 The large language model made tests for the previous commits 2026-05-20 23:43:04 +02:00
adriah 4680f4b809 fix(model): add interface ip to SPF entry 2026-05-20 23:37:24 +02:00
ponas f3b0df1937 Add tests and migration for previous commit 2026-05-20 23:19:56 +02:00
adriah fdfce5ca6d feat(bind): add support for SPF per host 2026-05-20 23:06:01 +02:00
ponas f4950c6388 Reformat all files with ruff 2026-03-19 02:25:30 +01:00
ponas 94c2b03138 Use wider number input fields for domain admin 2026-03-19 02:23:07 +01:00
ponas 1fb0b8385f Bump all dependencies, Python to 3.14 2026-03-19 02:18:42 +01:00
ponas a7dba1fe2e Add timezones to test data datetimes 2026-03-19 02:10:49 +01:00
ponas 8b002d4620 Convert old tests to pytest 2026-03-19 02:05:30 +01:00
ponas a2fcc7321d Send separate zone update diffs on succses 2026-03-19 01:34:45 +01:00
ponas a5c06bdce5 Revamp dhcp sync and add tests 2026-03-19 01:24:54 +01:00
ponas 0bc8daeabc Switch from email notifications to Discord webhook for bind changes 2026-03-19 00:49:51 +01:00
ponas e96948080f Revamp zone sync and add tests 2026-03-19 00:28:39 +01:00
ponas 0123dd1e3b Revert "Downgrade to be able to connect to MariaDB 10.3"
This reverts commit 4269569302.
2025-05-30 17:58:31 +02:00
ponas 7957459e06 Allow longer (chunked) TXT records 2025-05-12 03:26:17 +02:00
ponas 4269569302 Downgrade to be able to connect to MariaDB 10.3 2025-05-12 03:21:43 +02:00
ponas c935936ddc Use gettext_lazy 2025-05-12 03:07:58 +02:00
ponas 4b7952a6eb Switch to uv, bump deps 2025-05-12 02:52:39 +02:00
ponas 87d88035d9 fabfile: Drop the --no-dev 2021-10-08 22:32:53 +02:00
ponas 4c924377cf fabfile: Fix poetry install command 2021-10-08 22:29:02 +02:00
ponas d095674a89 Modify fabfile to use poetry 2021-10-08 22:25:47 +02:00
ponas 7e9b758421 Set Python requirement to ^3.6, add fabric 2021-10-08 22:25:30 +02:00
ponas 87a67dab07 Add DEFAULT_AUTO_FIELD 2021-10-08 22:15:27 +02:00
ponas 55c0a3453f ⬆ Bump dependencies, notably Django to 3.2.8 2021-10-08 22:13:57 +02:00
ponas 675b0e1344 Stop using django-suit 2021-10-08 22:00:08 +02:00
ponas c338680787 Replace requirements.txt with pyproject.toml and poetry.lock 2021-10-08 21:59:53 +02:00
nikolark ecc7ec0de8 🚀 Add fabfile for easier deployment 2020-01-05 21:54:27 +01:00
nikolark 1d7860dfee ⬆ Upgrade to django 2.2
Also bump django suit to v2 alpha
Remove python2 transitional support
2020-01-05 21:12:18 +01:00
Toralf Neby berge 47b69a9ea7 Remove unnecessary if statement 2018-03-10 15:52:40 +01:00
Toralf Neby berge 7de3e4008c Add check if macaddr is unique in the selected subnet 2018-03-10 15:37:27 +01:00
nikolark 54180d6bb5 refactor sync cmds 2017-01-05 00:29:16 +01:00
nikolark 49183fb646 Add sentry 2017-01-04 21:36:54 +01:00
nikolark cf6b1ad13d bump deps 2017-01-04 21:24:58 +01:00
nikolark 51b595a11d feat: better pxe error message 2016-12-07 22:26:54 +01:00
nikolark 093fae0fea admin: minor 2016-08-06 17:17:24 +02:00
nikolark b79fdafaa1 bind: fix reload of dynamic zones 2016-08-06 17:17:17 +02:00
nikolark 8fe8a1b311 Fix dhcpd restart 2016-07-26 13:12:59 +02:00
nikolark 935e38d224 Host: Make ipv6 enabled sortable++ 2016-07-25 18:39:10 +02:00
nikolark 2fd8001ba5 fix context_diff in python3 2016-07-25 18:06:44 +02:00
nikolark ca76aaa80b add gunicorn and bump deps 2016-07-25 17:47:55 +02:00
nikolark 456bcb7c50 More cleanups 2016-06-19 18:31:33 +02:00
nikolark 277fde9809 Misc pep8 cleanups 2016-06-19 18:29:07 +02:00
nikolark 257b00be1b feat(pxe): Add support for PXE config per host/os 2016-03-25 15:00:25 +01:00
nikolark 3659215a2f refactor(pxe): Move pxe functions to own file 2016-03-25 13:52:34 +01:00
nikolark 7b47e1d6d9 refactor(admin): Move admin specific code out of models 2016-03-25 13:41:58 +01:00
nikolark e17bf339e2 chore(app): Bump deps 2016-03-25 13:29:38 +01:00
nikolark 9d737dcd45 Merge branch 'hostadmin-perf' into 'master'
Hostadmin perf

very fast

See merge request !2
2016-01-07 23:40:40 +01:00
nikolark 898578852d select_related to the rescue, such performance! 2016-01-07 23:39:11 +01:00
nikolark 2a0744f602 Override form in InterfaceInline to get to expensive lookup 2016-01-06 22:39:32 +01:00
nikolark 15cde2cce4 Fixes broken zone-file when Domain.domain_ip6addr is empty 2015-11-03 11:02:19 +01:00
nikolark d523b4ae99 Merge branch 'ipv6-domain-addr' 2015-10-15 21:18:13 +02:00
nikolark fdb826d5ac Clear out pxe_key on validation 2015-10-15 21:17:28 +02:00
nikolark bcb4b2e713 Try fixing pxelinux.cfg 2015-10-14 21:33:35 +02:00
nikolark 4daf775adb Set less permissive filemode on pxe file, fix force param in zone-sync 2015-10-14 20:44:43 +02:00
nikolark 2df1f6ab36 PXE: Use one less dash for our slightly older kernel 2015-10-14 19:53:34 +02:00
Stian Wiken 5ece0c21f9 Better pxeconfig template 2015-10-07 20:00:09 +02:00
nikolark 8946234b10 Merge branch 'bump-bonanza-and-python3' 2015-10-07 10:42:55 +02:00
79 changed files with 3918 additions and 7671 deletions

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
name: CI
# Tests and prek on every push and pull request. A push to main that passes both deploys
# to sega, while the repository variable DEPLOY_SEGA is true.
on:
push:
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# bind9-utils provides named-checkzone, used by the zone sync tests.
- name: Install system dependencies
run: |
SUDO=$(command -v sudo || true)
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends bind9-utils
- name: Install mise
run: |
curl -fsSL https://mise.run | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install pinned tools
run: mise install
- name: Install Python dependencies
run: mise exec -- uv sync --locked --no-group prod
- name: Unit tests
run: mise exec -- uv run --no-sync pytest
# The hooks in prek.toml.
prek:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: j178/prek-action@v1
# sega's authorized_keys sends this key to mdb-deploy, which reads the sha from the ssh
# command line, builds that commit on the host and switches to it (edb-infra's
# roles/mdb.md).
deploy-sega:
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_SEGA == 'true'
runs-on: ubuntu-latest
needs:
- tests
- prek
steps:
- name: Deploy
run: |
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
eval $(ssh-agent -s)
echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add -
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
ssh mdb-deploy@sega.neuf.no "${{ github.sha }}"
+10
View File
@@ -0,0 +1,10 @@
# Agents
## Commits
Use [Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <subject>`,
with the scope optional. The types in use are `feat`, `fix`, `test`, `ci`, `chore`, `style`,
`refactor` and `docs`. For example:
- `fix(zone_sync): thaw zones when the sync is killed`
- `ci: one workflow for tests, prek and the deploy to sega`
+36 -11
View File
@@ -1,21 +1,46 @@
A Django app for managing computer equipment. A Django app for managing computer equipment.
## Installation ## Installation
sudo apt install libmysqlclient-dev python-dev libldap2-dev libsasl2-dev bind9utils
virtualenv venv Install system dependencies:
. venv/bin/activate
pip install -r requirements.txt sudo apt install python-dev libldap2-dev libsasl2-dev bind9-utils
python manage.py migrate
python manage.py runserver Use [mise](https://mise.jdx.dev/) to manage Python and uv:
mise trust
mise install
Install Python dependencies with uv:
uv sync
Run migrations and start the development server:
uv run python manage.py migrate
uv run python manage.py runserver
`mise.toml` sets `DJANGO_DEBUG=1`; without it `DEBUG` is off. Local overrides go in
`mdbsite/local_settings.py`.
## Production
MDB runs on sega under edb-infra's `mdb` role (`roles/mdb.md` there): gunicorn from a
release directory per commit, its settings in the file `MDB_SETTINGS` names
(`/etc/mdb/settings.py`), which `mdbsite/settings.py` executes last. A push to `main` that
passes the tests and prek deploys itself while the repository variable `DEPLOY_SEGA` is `true`
(`.gitea/workflows/ci.yaml`). The DNS and DHCP sync runs as root on sega:
`sudo mdb-sync`.
## Development tasks ## Development tasks
Dump test data: Dump test data:
python manage.py dumpdata mdb --natural-foreign --indent=4 > mdb/fixtures/test_data.json uv run python manage.py dumpdata mdb --natural-foreign --indent=4 > mdb/fixtures/test_data.json
## Features ## Features
* Creates DHCPD configuration files - Creates DHCPD configuration files
* Creates DNS zone files for BIND - Creates DNS zone files for BIND
* Creates PXE config files (with random secret for use with Puppet autosign) - Netboots hosts marked PXE installable, by setting their DHCP boot filename
* Partial IPv6 support - Partial IPv6 support
+1
View File
@@ -5,4 +5,5 @@ import sys
if __name__ == "__main__": if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdbsite.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdbsite.settings")
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)
-1
View File
@@ -1 +0,0 @@
default_app_config = 'mdb.apps.MdbAppConfig'
+237 -51
View File
@@ -1,8 +1,45 @@
from django.contrib import admin, messages import ipaddress
from mdb.models import Ip6Address, Interface, Ip4Address, DhcpOption, DhcpCustomField, DomainSrvRecord, DomainTxtRecord, \ from django.conf import settings
DomainCnameRecord, DomainARecord, Domain, Host, Ip4Subnet, Ip6Subnet, Nameserver, MailExchange, OperatingSystem, \ from django.contrib import admin, messages
HostType, DhcpConfig, DomainAAAARecord from django.db import models
from django.db.models import Count
from django.forms import NumberInput
from mdb.forms import InterfaceForm, SerialNumberInput
from mdb.models import (
DhcpConfig,
DhcpCustomField,
DhcpOption,
Domain,
DomainAAAARecord,
DomainARecord,
DomainCnameRecord,
DomainSrvRecord,
DomainTxtRecord,
Host,
HostType,
Interface,
Ip4Address,
Ip4Subnet,
Ip6Address,
Ip6Subnet,
MailExchange,
Nameserver,
OperatingSystem,
)
admin.site.site_header = settings.ADMIN_SITE_NAME
class SerialFieldAdminMixin:
"""Render the ``domain_serial`` field with the wide serial widget + button."""
def formfield_for_dbfield(self, db_field, request, **kwargs):
formfield = super().formfield_for_dbfield(db_field, request, **kwargs)
if db_field.name == "domain_serial":
formfield.widget = SerialNumberInput()
return formfield
class Ip6AddressInline(admin.TabularInline): class Ip6AddressInline(admin.TabularInline):
@@ -14,64 +51,123 @@ class InterfaceInline(admin.TabularInline):
inlines = [Ip6AddressInline] inlines = [Ip6AddressInline]
model = Interface model = Interface
extra = 0 extra = 0
form = InterfaceForm
def get_queryset(self, request): def get_queryset(self, request):
return super(InterfaceInline, self).get_queryset(request).select_related() return super().get_queryset(request).select_related("host", "domain")
@admin.register(Host)
class HostAdmin(admin.ModelAdmin): class HostAdmin(admin.ModelAdmin):
ordering = ('hostname',) ordering = ("hostname",)
inlines = [InterfaceInline] inlines = [InterfaceInline]
list_display = ['hostname', 'owner', 'host_type', 'location', 'mac_addresses', 'ip_addresses', 'in_domain', list_display = [
'pxe_installable', 'ipv6_enabled'] "hostname",
list_filter = ['host_type', 'owner', 'location', 'pxe_installable'] "owner",
readonly_fields = ['kerberos_principal_name', 'kerberos_principal_created_date', "host_type",
'kerberos_principal_created'] "location",
search_fields = ['hostname', 'location', 'interface__macaddr', 'interface__ip4address__address'] "mac_addresses",
"ip_addresses",
"in_domain",
"pxe_installable",
"ipv6_enabled",
"allow_email",
]
list_filter = ["host_type", "owner", "location", "pxe_installable", "allow_email"]
readonly_fields = [
"kerberos_principal_name",
"kerberos_principal_created_date",
"kerberos_principal_created",
]
search_fields = [
"hostname",
"location",
"interface__macaddr",
"interface__ip4address__address",
]
fieldsets = ( fieldsets = (
('Owner Information', { ("Owner Information", {"fields": ("owner", "location", "description")}),
'fields': ('owner', 'location', 'description') (
}), "Hardware and Software Information",
('Hardware and Software Information', { {
'fields': (('hostname', 'host_type'), ('pxe_key', 'pxe_installable'), ('brand', 'model'), 'serial_number', ('operating_system', 'virtual')) "fields": (
}), ("hostname", "host_type"),
# FIXME: Not in use ("pxe_key", "pxe_installable"),
# ('Domain and Kerberos Information', { ("brand", "model"),
# 'description': 'If this host is a member of the LDAP domain, you need to tick the request kerberos principal checkbox. A principal will then be created for the host.', "serial_number",
# 'classes': ['collapse'], ("operating_system", "virtual"),
# 'fields': ('request_kerberos_principal', 'kerberos_principal_created', "allow_email",
# ('kerberos_principal_name', 'kerberos_principal_created_date'))
# }),
) )
actions = ['set_installable'] },
),
)
actions = ["set_installable"]
def _get_host_warning_message(self, host, ifs): def mac_addresses(self, host):
ifs_str = ', '.join([_if.name for _if in ifs]) addresses = host.interface_set.filter(macaddr__isnull=False).values_list(
return 'Host {} might not be installable since interface(s) {} has no IP set.'.format(host, ifs_str) "macaddr", flat=True
)
return ", ".join(addresses)
def ip_addresses(self, host):
addresses = host.interface_set.filter(ip4address__isnull=False).values_list(
"ip4address__address", flat=True
)
return ", ".join(addresses)
@admin.display(description="in domains")
def in_domain(self, host):
domains = host.interface_set.values_list("domain__domain_name", flat=True)
return ",".join(domains)
@admin.display(boolean=True)
def ipv6_enabled(self, host):
ipv6_ifs = host.interface_set.annotate(num_ipv6=Count("ip6address")).filter(
num_ipv6__gt=0
)
return ipv6_ifs.exists()
# FIXME: custom filter to sort custom field
@staticmethod
def _get_host_warning_message(host, ifs):
ifs_str = ", ".join([_if.name for _if in ifs])
return f"Host {host} might not be installable since interface(s) {ifs_str} has no IP set."
@admin.action(description="Mark selected hosts as PXE installable")
def set_installable(self, request, queryset): def set_installable(self, request, queryset):
invalid_ifs_per_host = [] invalid_ifs_per_host = []
for obj in queryset: for obj in queryset:
invalid_ifs = obj.interface_set.filter(ip4address__isnull=True) invalid_ifs = obj.interface_set.filter(ip4address__isnull=True)
if invalid_ifs.exists(): if invalid_ifs.exists():
invalid_ifs_per_host.append(self._get_host_warning_message(obj, invalid_ifs)) invalid_ifs_per_host.append(
self._get_host_warning_message(obj, invalid_ifs)
)
obj.pxe_installable = True obj.pxe_installable = True
obj.save() # triggers post save signal obj.save() # triggers post save signal
if invalid_ifs_per_host: if invalid_ifs_per_host:
self.message_user(request, message='\n'.join(invalid_ifs_per_host), level=messages.WARNING) self.message_user(
request, message="\n".join(invalid_ifs_per_host), level=messages.WARNING
set_installable.short_description = "Mark selected hosts as PXE installable" )
def get_queryset(self, request): def get_queryset(self, request):
return super(HostAdmin, self).get_queryset(request).select_related() return (
super()
.get_queryset(request)
.select_related("host_type", "operating_system")
)
class Ip4AddressInline(admin.TabularInline): class Ip4AddressInline(admin.TabularInline):
model = Ip4Address model = Ip4Address
extra = 0 extra = 0
readonly_fields = ['address', 'assigned_to_host'] readonly_fields = ["address", "assigned_to_host"]
@admin.display(description="Assigned to Host")
def assigned_to_host(self, addr):
return addr.interface.host
class DhcpOptionInline(admin.TabularInline): class DhcpOptionInline(admin.TabularInline):
@@ -84,13 +180,76 @@ class DhcpCustomFieldInline(admin.TabularInline):
extra = 0 extra = 0
class SubnetAdmin(admin.ModelAdmin): @admin.register(Ip4Subnet)
list_display = ['name', 'network', 'netmask', 'num_addresses', 'broadcast_address', 'first_address', 'last_address'] class Ip4SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
list_display = [
"name",
"network",
"netmask",
"domain_name",
"num_addresses",
"broadcast_address",
"first_address",
"last_address",
]
readonly_fields = ["domain_name"]
inlines = [DhcpOptionInline, DhcpCustomFieldInline, Ip4AddressInline] inlines = [DhcpOptionInline, DhcpCustomFieldInline, Ip4AddressInline]
@admin.display(description="# addresses")
def num_addresses(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return subnet.num_addresses
@admin.display(description="broadcast")
def broadcast_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return subnet.broadcast_address
@admin.display(description="first address")
def first_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return next(subnet.hosts())
@admin.display(description="last address")
def last_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return list(subnet.hosts())[-1]
def get_queryset(self, request): def get_queryset(self, request):
# TODO: optimize # TODO: optimize
return super(SubnetAdmin, self).get_queryset(request) return super().get_queryset(request)
@admin.register(Ip6Subnet)
class Ip6SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
list_display = [
"name",
"network",
"netmask",
"domain_name",
"num_addresses",
"first_address",
"last_address",
]
readonly_fields = ["domain_name"]
def _network(self, ip6subnet):
return ipaddress.IPv6Network(
f"{ip6subnet.network}::/{ip6subnet.netmask:d}", strict=False
)
@admin.display(description="# addresses")
def num_addresses(self, ip6subnet):
return self._network(ip6subnet).num_addresses
@admin.display(description="first address")
def first_address(self, ip6subnet):
return self._network(ip6subnet).network_address
@admin.display(description="last address")
def last_address(self, ip6subnet):
# subnet[-1] is O(1); never materialize hosts() for a /64.
return self._network(ip6subnet)[-1]
class DomainSrvRecordInline(admin.TabularInline): class DomainSrvRecordInline(admin.TabularInline):
@@ -118,29 +277,56 @@ class DomainAAAARecordInline(admin.TabularInline):
extra = 0 extra = 0
class DomainAdmin(admin.ModelAdmin): @admin.register(Domain)
inlines = [DomainSrvRecordInline, DomainTxtRecordInline, DomainARecordInline, DomainAAAARecordInline, class DomainAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
DomainCnameRecordInline] inlines = [
list_display = ['domain_name', 'domain_soa', 'domain_admin', 'num_records', 'domain_ipaddr', 'domain_ip6addr'] DomainSrvRecordInline,
search_fields = ['domain_name'] DomainTxtRecordInline,
DomainARecordInline,
DomainAAAARecordInline,
DomainCnameRecordInline,
]
list_display = [
"domain_name",
"domain_soa",
"domain_admin",
"num_records",
"domain_ipaddr",
"domain_ip6addr",
]
search_fields = ["domain_name"]
formfield_overrides = {
models.IntegerField: {"widget": NumberInput(attrs={"style": "width: 10em"})},
}
@admin.display(description="Num Records")
def num_records(self, domain):
host_a_records = Host.objects.filter(interface__domain=domain).count()
size = {
"CNAME": domain.domaincnamerecord_set.count(),
"SRV": domain.domainsrvrecord_set.count(),
"TXT": domain.domaintxtrecord_set.count(),
"A": domain.domainarecord_set.count() + host_a_records,
}
return ", ".join([f"{k}: {v}" for k, v in size.items()])
@admin.register(HostType)
class HostTypeAdmin(admin.ModelAdmin): class HostTypeAdmin(admin.ModelAdmin):
list_display = ['host_type', 'description', 'num_members'] list_display = ["host_type", "description", "num_members"]
@admin.register(OperatingSystem)
class OperatingSystemAdmin(admin.ModelAdmin): class OperatingSystemAdmin(admin.ModelAdmin):
list_display = ['name', 'version', 'arch'] list_display = [
"name",
"version",
"arch",
]
admin.site.register(Domain, DomainAdmin)
admin.site.register(Host, HostAdmin)
admin.site.register(Ip4Subnet, SubnetAdmin)
admin.site.register(Ip6Subnet)
admin.site.register(Ip6Address) admin.site.register(Ip6Address)
admin.site.register(Nameserver) admin.site.register(Nameserver)
admin.site.register(MailExchange) admin.site.register(MailExchange)
admin.site.register(OperatingSystem, OperatingSystemAdmin)
admin.site.register(HostType, HostTypeAdmin)
admin.site.register(DhcpConfig) admin.site.register(DhcpConfig)
admin.site.register(DhcpOption) admin.site.register(DhcpOption)
+23 -11
View File
@@ -1,7 +1,8 @@
from mdb.models import Host
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from mdb.models import Host
class HostPXEValidateSerializer(serializers.Serializer): class HostPXEValidateSerializer(serializers.Serializer):
certname = serializers.CharField(required=True) certname = serializers.CharField(required=True)
@@ -9,13 +10,15 @@ class HostPXEValidateSerializer(serializers.Serializer):
host = None host = None
def _get_host_by_fqdn(self, attrs): def _get_host_by_fqdn(self, attrs):
fqdn = attrs['certname'] fqdn = attrs["certname"]
host_name = fqdn.split('.')[0] host_name = fqdn.split(".")[0]
domain = '.'.join(fqdn.split('.')[1:]) domain = ".".join(fqdn.split(".")[1:])
try: try:
return Host.objects.get(hostname=host_name, interface__domain__domain_name=domain) return Host.objects.get(
except (Host.DoesNotExist, Host.MultipleObjectsReturned): hostname=host_name, interface__domain__domain_name=domain
)
except Host.DoesNotExist, Host.MultipleObjectsReturned:
return None return None
def validate(self, attrs): def validate(self, attrs):
@@ -23,16 +26,24 @@ class HostPXEValidateSerializer(serializers.Serializer):
host = self._get_host_by_fqdn(attrs) host = self._get_host_by_fqdn(attrs)
if host is None: if host is None:
raise ValidationError('Host \'{}\' not found.'.format(attrs['certname'])) raise ValidationError("Host '{}' not found.".format(attrs["certname"]))
if not host.pxe_installable: if not host.pxe_installable:
raise ValidationError('Host \'{}\' is marked as not installable via PXE.'.format(attrs['certname'])) raise ValidationError(
"Host '{}' is marked as not installable via PXE.".format(
attrs["certname"]
)
)
if not host.pxe_key: if not host.pxe_key:
raise ValidationError('Host \'{}\' has no pxe_key.'.format(attrs['certname'])) raise ValidationError("Host '{}' has no pxe_key.".format(attrs["certname"]))
if host.pxe_key != attrs['pxe_key']: if host.pxe_key != attrs["pxe_key"]:
raise ValidationError('Supplied pxe_key does not match host \'{}\'.'.format(attrs['certname'])) raise ValidationError(
"Supplied pxe_key '{}' does not match host '{}'.".format(
attrs["pxe_key"], attrs["certname"]
)
)
self.host = host self.host = host
@@ -41,6 +52,7 @@ class HostPXEValidateSerializer(serializers.Serializer):
def create(self, validated_data): def create(self, validated_data):
# Make requests kind of idempotent by only allowing 1 request / installation # Make requests kind of idempotent by only allowing 1 request / installation
self.host.pxe_installable = False self.host.pxe_installable = False
self.host.pxe_key = ""
self.host.save() self.host.save()
return self.host return self.host
+2 -2
View File
@@ -15,6 +15,6 @@ class HostPXEValidate(APIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
host = serializer.save() # sets Host.pxe_installable=False serializer.save() # sets Host.pxe_installable=False
return Response({'valid_pxe_key': True}) return Response({"valid_pxe_key": True})
+3 -3
View File
@@ -2,9 +2,9 @@ from django.apps import AppConfig
class MdbAppConfig(AppConfig): class MdbAppConfig(AppConfig):
name = 'mdb' name = "mdb"
verbose_name = 'Machine Database' verbose_name = "Machine Database"
def ready(self): def ready(self):
# all models are loaded, now attach signals # all models are loaded, now attach signals
import mdb.signals import mdb.signals # noqa
+17 -8
View File
@@ -34,7 +34,7 @@
"domain_active_serial": 0, "domain_active_serial": 0,
"domain_ip6addr": "::1", "domain_ip6addr": "::1",
"domain_refresh": 28800, "domain_refresh": 28800,
"created_date": "2015-10-03T13:02:53.581", "created_date": "2015-10-03T13:02:53.581Z",
"domain_soa": "sega.neuf.no", "domain_soa": "sega.neuf.no",
"domain_admin": "sysadmin@neuf.no", "domain_admin": "sysadmin@neuf.no",
"domain_minimum_ttl": 86400, "domain_minimum_ttl": 86400,
@@ -51,7 +51,7 @@
{ {
"fields": { "fields": {
"domain": 1, "domain": 1,
"created_date": "2015-10-04T17:13:46.810", "created_date": "2015-10-04T17:13:46.810Z",
"name": "www", "name": "www",
"target": "edb.technology." "target": "edb.technology."
}, },
@@ -61,7 +61,7 @@
{ {
"fields": { "fields": {
"domain": 1, "domain": 1,
"created_date": "2015-10-04T17:13:46.807", "created_date": "2015-10-04T17:13:46.807Z",
"name": "test", "name": "test",
"target": "127.0.0.1" "target": "127.0.0.1"
}, },
@@ -71,7 +71,7 @@
{ {
"fields": { "fields": {
"domain": 1, "domain": 1,
"created_date": "2015-10-04T17:13:46.809", "created_date": "2015-10-04T17:13:46.809Z",
"name": "test", "name": "test",
"target": "::1" "target": "::1"
}, },
@@ -103,7 +103,7 @@
"netmask": 64, "netmask": 64,
"domain_active_serial": 0, "domain_active_serial": 0,
"domain_refresh": 28800, "domain_refresh": 28800,
"created_date": "2015-10-04T12:50:12.248", "created_date": "2015-10-04T12:50:12.248Z",
"domain_soa": "sega.neuf.no", "domain_soa": "sega.neuf.no",
"domain_admin": "sysadmin@neuf.no", "domain_admin": "sysadmin@neuf.no",
"domain_minimum_ttl": 86400, "domain_minimum_ttl": 86400,
@@ -132,7 +132,7 @@
"netmask": "255.255.255.0", "netmask": "255.255.255.0",
"domain_active_serial": 0, "domain_active_serial": 0,
"domain_refresh": 28800, "domain_refresh": 28800,
"created_date": "2015-10-03T13:08:17.765", "created_date": "2015-10-03T13:08:17.765Z",
"domain_soa": "sega.neuf.no", "domain_soa": "sega.neuf.no",
"domain_admin": "sysadmin@neuf.no", "domain_admin": "sysadmin@neuf.no",
"domain_minimum_ttl": 86400, "domain_minimum_ttl": 86400,
@@ -2738,6 +2738,15 @@
"model": "mdb.operatingsystem", "model": "mdb.operatingsystem",
"pk": 1 "pk": 1
}, },
{
"fields": {
"arch": "x86_64",
"name": "Ubuntu",
"version": "16.04-beta-final"
},
"model": "mdb.operatingsystem",
"pk": 2
},
{ {
"fields": { "fields": {
"kerberos_principal_name": "", "kerberos_principal_name": "",
@@ -2751,7 +2760,7 @@
"pxe_installable": true, "pxe_installable": true,
"kerberos_principal_created_date": null, "kerberos_principal_created_date": null,
"location": "kak", "location": "kak",
"created_date": "2015-10-03T13:23:37.061", "created_date": "2015-10-03T13:23:37.061Z",
"owner": "edb", "owner": "edb",
"serial_number": "12345678", "serial_number": "12345678",
"model": "OptiPlex 760", "model": "OptiPlex 760",
@@ -2770,7 +2779,7 @@
"host": 1, "host": 1,
"macaddr": "f0:de:f1:58:be:1a", "macaddr": "f0:de:f1:58:be:1a",
"ip4address": 2, "ip4address": 2,
"created_date": "2015-10-03T13:23:37.063" "created_date": "2015-10-03T13:23:37.063Z"
}, },
"model": "mdb.interface", "model": "mdb.interface",
"pk": 1 "pk": 1
+91
View File
@@ -0,0 +1,91 @@
from django import forms
from django.core.exceptions import ValidationError
from django.forms import ModelChoiceField, NumberInput
from django.utils.safestring import mark_safe
from mdb.models import Interface, Ip4Address
class SerialNumberInput(NumberInput):
"""Number input for DNS SOA serials (YYYYMMDDXX).
Renders wide enough to show the full 10-digit serial and adds a button
that prefills the field with the next serial: today's first, or one more than
the current serial when that is higher.
"""
def __init__(self, attrs=None):
default_attrs = {"style": "width: 12em"}
if attrs:
default_attrs.update(attrs)
super().__init__(default_attrs)
def render(self, name, value, attrs=None, renderer=None):
field = super().render(name, value, attrs, renderer)
field_id = (attrs or {}).get("id", f"id_{name}")
extra = """
<button type="button" class="button" style="margin-left: 0.5em;"
onclick="mdbBumpSerial('__ID__')">Today + next serial</button>
<script>
if (typeof window.mdbBumpSerial === 'undefined') {
window.mdbBumpSerial = function (inputId) {
var input = document.getElementById(inputId);
if (!input) { return; }
var now = new Date();
var today = '' + now.getFullYear()
+ String(now.getMonth() + 1).padStart(2, '0')
+ String(now.getDate()).padStart(2, '0');
// As mdb.utils.format_domain_serial_and_add_one: never lower than the current
// serial, so past XX = 99 it carries into the date.
var current = parseInt((input.value || '').trim(), 10);
var next = parseInt(today + '01', 10);
if (!isNaN(current) && current + 1 > next) {
next = current + 1;
}
input.value = String(next);
};
}
</script>
""".replace("__ID__", field_id)
return mark_safe(field + extra)
class Ip4ModelChoiceField(ModelChoiceField):
def label_from_instance(self, obj):
if not hasattr(obj, "interface"):
return obj.address
return f"{obj.address} ({obj.interface.host.hostname})"
class InterfaceForm(forms.ModelForm):
class Meta:
model = Interface
fields = "__all__"
def clean(self):
cleaned_data = super().clean()
# Missing when the field itself failed validation, and the address is optional.
macaddr = cleaned_data.get("macaddr")
ip4address = cleaned_data.get("ip4address")
if not macaddr or ip4address is None:
return cleaned_data
# dhcpd matches MAC addresses whatever their case.
clashes = Interface.objects.filter(
macaddr__iexact=macaddr, ip4address__subnet=ip4address.subnet
).exclude(pk=self.instance.pk)
if clashes.exists():
raise ValidationError("Macaddr already in this subnet")
return cleaned_data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Join Ip4Address with tables interface and host,
# before printing the select labels that walk those two relations.
queryset = Ip4Address.objects.select_related("interface", "interface__host")
# Not required, as on the model: a host can have interfaces without IPv4.
self.fields["ip4address"] = Ip4ModelChoiceField(
queryset=queryset, required=False
)
-106
View File
@@ -1,106 +0,0 @@
from __future__ import with_statement
import os
import sys
from difflib import context_diff
from django.core.management.base import BaseCommand
from django.core.mail import mail_admins
from optparse import make_option
try:
from subprocess import getstatusoutput
except ImportError:
from commands import getstatusoutput
from mdb.models import DhcpConfig
class Command(BaseCommand):
args = '--output <path> [--force] [--restart]'
help = 'Generates the DHCP configuration'
option_list = BaseCommand.option_list + (
make_option('--output',
action='store',
dest='output',
default=None,
help='Path to dhcpd.conf, defaults to stdout'),
make_option('--force',
action='store_true',
dest='force',
default=False,
help='Write configuration even if serial is unchanged.'),
make_option('--restart',
action='store_true',
dest='restart',
default=False,
help='Restart dhcp3-server if serial is changed'),
)
def handle(self, *args, **options):
output = options['output'] if options['output'] else sys.stdout
force = options['force']
restart = options['restart']
restart_command = "service isc-dhcp-server restart"
config = DhcpConfig.objects.filter(name="default").get()
# do we need to do anything?
if config.serial == config.active_serial and not force:
print("Nothing to do, serial is the same: %s" % config.serial)
sys.exit(0)
# write to stdout
if output == sys.stdout:
print(config.dhcpd_configuration())
print('Not writing to file, serial not updated')
return
# read current file contents
try:
with open(output, 'r') as f:
old = f.read()
except IOError:
old = ''
# write to file
new = config.dhcpd_configuration()
with open(output, 'w') as f:
f.write(new)
# calculate diff
diff = "\n".join(context_diff(
a=old.splitlines(),
b=new.splitlines(),
fromfile=config.active_serial,
tofile=config.serial,
lineterm=''))
print(diff)
# update serial
old_serial = config.active_serial
config.active_serial = config.serial
config.save()
# restart the dhcp server?
if restart:
status, output = getstatusoutput(restart_command)
if status != 0:
message = (
"Failed to restart dhcp3-server, please inspect...\n\n" +
"%s@%s# %s" % (os.getlogin(), os.uname()[1], restart_command) +
"\n%s\n\n%s" % (output, diff))
mail_admins(
subject="FAIL: dhcpd %s -> %s" % (old_serial, config.active_serial),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(1)
else:
message = (
"Updated dhcpd configuration to serial %d\n\n" % config.active_serial +
"%s" % diff)
mail_admins(
subject="Success: dhcpd %s -> %s" % (old_serial, config.active_serial),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(0)
+83
View File
@@ -0,0 +1,83 @@
import getpass
import os
from django.core.management.base import BaseCommand
from mdb.services import discord
from mdb.services.dhcp_sync import DhcpSyncService
class Command(BaseCommand):
args = "--output <path> [--force] [--restart]"
help = "Generates the DHCP configuration"
def add_arguments(self, parser):
parser.add_argument(
"--output",
action="store",
dest="output",
default=None,
help="Path to dhcpd.conf, defaults to stdout",
)
parser.add_argument(
"--force",
action="store_true",
dest="force",
default=False,
help="Write configuration even if serial is unchanged.",
)
parser.add_argument(
"--restart",
action="store_true",
dest="restart",
default=False,
help="Restart isc-dhcp-server if serial is changed",
)
def handle(self, *args, **options):
output = options["output"]
force = options["force"]
restart = options["restart"]
service = DhcpSyncService(force=force)
config = service.get_config()
if not service.needs_update(config):
self.stdout.write(f"Nothing to do, serial is the same: {config.serial}\n")
return
if output is None:
self.stdout.write(config.dhcpd_configuration())
self.stdout.write("Not writing to file, serial not updated\n")
return
old_serial, diff = service.write_config(config, output)
self.stdout.write(diff + "\n")
if restart:
self.restart_and_notify(service, config, old_serial, diff)
def restart_and_notify(self, service, config, old_serial, diff):
self.stdout.write("restarting isc-dhcp-server... ")
success, output = service.restart_service()
if not success:
self.stdout.write("fail\n")
message = (
"Failed to restart isc-dhcp-server, please inspect...\n\n"
f"{getpass.getuser()}@{os.uname()[1]}$ {service.restart_command}\n{output}"
)
discord.notify(
subject=f"⚠️ FAIL: dhcpd {old_serial} -> {config.active_serial}",
message=message,
preformatted=diff or None,
)
raise SystemExit(1)
self.stdout.write("ok\n")
message = f"Updated dhcpd configuration to serial {config.active_serial:d}"
discord.notify(
subject=f"✅ Success: dhcpd {old_serial} -> {config.active_serial}",
message=message,
preformatted=diff or None,
)
-175
View File
@@ -1,175 +0,0 @@
from __future__ import with_statement
import os
import sys
from itertools import chain
from difflib import context_diff
from django.core.management.base import BaseCommand
from django.core.mail import mail_admins
from optparse import make_option
try:
from subprocess import getstatusoutput
except ImportError:
from commands import getstatusoutput
from mdb.models import Domain, Ip4Subnet, Ip6Subnet
class Command(BaseCommand):
args = '[--force] [--debug]'
help = 'Generates the bind configuration'
option_list = BaseCommand.option_list + (
make_option('--debug',
action='store_true',
dest='debug',
default=False,
help='Debug mode. Writes to /tmp and does not reload.'),
make_option('--force',
action='store_true',
dest='force',
default=False,
help='Write zone files even if serial is unchanged.'),
)
bind_bin = "/etc/init.d/bind9"
reload_command = "%s reload" % bind_bin
checkzone_bin = "/usr/sbin/named-checkzone"
checkzone_dir = "/tmp/mdb-checkzone"
debug = False
debug_dir = '/tmp/mdb-debug'
changes = {}
def handle(self, *args, **options):
self.debug = debug = options['debug']
force = options['force']
# do the binaries exist?
for f in (self.checkzone_bin, self.bind_bin):
if not os.path.isfile(f):
print("ERROR: no such file %s, exiting..." % f)
if not debug:
sys.exit(1)
# create directories if needed
os.path.isdir(self.checkzone_dir) or os.mkdir(self.checkzone_dir)
debug and (os.path.isdir(self.debug_dir) or os.mkdir(self.debug_dir))
# update zones with new serials
for zone in chain(Domain.objects.all(),
Ip4Subnet.objects.all(),
Ip6Subnet.objects.all()):
if zone.domain_serial == zone.domain_active_serial or force:
continue
if self.update_zone(zone):
self.changes[zone.domain_name]['success'] = True
# join errors, diffs and zone names
errors = "\n\n\n".join(
[v['errors'] for k, v in self.changes.items() if v['errors']])
diffs = "\n\n\n".join(
[v['diff'] for k, v in self.changes.items() if v['diff']])
zones = ", ".join(self.changes.keys())
# reload and send email with changes
if self.changes and not debug:
sys.stdout.write("reloading bind... ")
status, output = getstatusoutput(self.reload_command)
if status != 0:
sys.stdout.write("fail\n")
message = (
"Failed to reload bind, please inspect...\n\n" +
"%s@%s# %s" % (
os.getlogin(), os.uname()[1], self.reload_command) +
"\n%s\n\n\n%s\n\n\n%s" % (output, errors, diffs))
mail_admins(
subject="FAIL: bind (%s changed: %s)" % (len(self.changes), zones),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(1)
else:
sys.stdout.write("ok\n")
message = (
"Successfully updated bind zone files\n\n" +
"%s\n\n%s" % (errors, diffs))
mail_admins(
subject="Success: bind (%s changed: %s)" % (len(self.changes), zones),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(0)
def update_zone(self, zone):
zonetype = getattr(zone._meta, 'verbose_name', None) \
or zone._meta.model_name
self.changes[zone.domain_name] = {
'old_serial': zone.domain_active_serial,
'new_serial': zone.domain_serial,
'success': False,
'errors': None,
'diff': None,
}
print("updating %s %s [%d -> %d]" % (
zonetype,
zone.domain_name,
zone.domain_active_serial,
zone.domain_serial))
# generate zone file contents
new = zone.zone_file_contents()
# read current file contents
try:
with open(zone.domain_filename, 'r') as f:
old = f.read()
except IOError:
old = ''
# calculate diff
diff = "\n".join(context_diff(
a=old.splitlines(),
b=new.splitlines(),
fromfile=str(zone.domain_active_serial) + '-' + zone.domain_name,
tofile=str(zone.domain_serial) + '-' + zone.domain_name,
lineterm=''))
self.changes[zone.domain_name]['diff'] = diff
# validate with zonecheck
sys.stdout.write("\t- validating... ")
status, output = self.check_zone(zone, new)
if status != 0:
sys.stdout.write("fail\n")
self.changes[zone.domain_name]['errors'] = output
return False
sys.stdout.write("ok\n")
# write to zone file
sys.stdout.write("\t- writing zone file... ")
if not self.debug:
zonefile = zone.domain_filename
else:
zonefile = self.debug_dir + zone.domain_filename
path = "/".join(zonefile.split('/')[:-1])
os.path.isdir(path) or os.makedirs(path)
with open(zonefile, 'w') as f:
f.write(new)
# update serial
if not self.debug:
zone.domain_active_serial = zone.domain_serial
zone.save()
sys.stdout.write("ok\n")
self.changes[zone.domain_name]['success'] = True
return True
def check_zone(self, zone, contents):
zonefile = self.checkzone_dir + '/' + zone.domain_name
with open(zonefile, 'w') as f:
f.write(contents)
return getstatusoutput("%s %s %s" % (
self.checkzone_bin, zone.domain_name, zonefile))
+215
View File
@@ -0,0 +1,215 @@
import getpass
import os
import signal
from django.core.management.base import BaseCommand
from mdb.services import discord
from mdb.services.zone_sync import ZoneSyncService
# What stops a sync run by hand: a dropped ssh session, a kill, Ctrl-C.
STOPPING_SIGNALS = (signal.SIGHUP, signal.SIGTERM, signal.SIGINT)
class Interrupted(Exception):
def __init__(self, signum):
self.signum = signum
super().__init__(signal.Signals(signum).name)
def raise_interrupted(signum, frame):
raise Interrupted(signum)
def set_handlers(handler):
for signum in STOPPING_SIGNALS:
signal.signal(signum, handler)
class Command(BaseCommand):
args = "[--force] [--debug]"
help = "Generates the bind configuration"
def add_arguments(self, parser):
parser.add_argument(
"--debug",
action="store_true",
dest="debug",
default=False,
help="Debug mode. Writes to /tmp and does not reload.",
)
parser.add_argument(
"--force",
action="store_true",
dest="force",
default=False,
help="Write zone files even if serial is unchanged.",
)
parser.add_argument(
"--zone",
dest="zone",
default=None,
metavar="ZONE",
help="Sync a single zone by name instead of all zones.",
)
def handle(self, *args, **options):
debug = options["debug"]
force = options["force"]
zone_name = options["zone"]
service = ZoneSyncService(debug=debug, force=force)
service.validate_binaries_exist()
os.path.isdir(service.checkzone_dir) or os.mkdir(service.checkzone_dir)
debug and (os.path.isdir(service.debug_dir) or os.mkdir(service.debug_dir))
if not debug:
self.thaw_after_killed_sync(service)
if zone_name:
zone = service.find_zone(zone_name)
if zone is None:
self.stderr.write(f"error: zone '{zone_name}' not found\n")
raise SystemExit(1)
zones = [zone]
else:
zones = service.pending_zones()
if not zones:
return
if debug:
for zone in zones:
service.sync_zone(zone)
return
previous = {signum: signal.getsignal(signum) for signum in STOPPING_SIGNALS}
try:
# From the freeze to the thaw, a signal must not stop the sync in between:
# ignored while rndc runs, it stops the writes for a reload and thaw.
set_handlers(signal.SIG_IGN)
self.freeze(service, zones)
set_handlers(raise_interrupted)
try:
for zone in zones:
service.sync_zone(zone)
except BaseException as e:
set_handlers(signal.SIG_IGN)
self.reload_after_interruption(service, zones, e)
if isinstance(e, Interrupted):
raise SystemExit(128 + e.signum) from e
raise
set_handlers(signal.SIG_IGN)
self.reload_and_notify(service, service.changes)
finally:
for signum, handler in previous.items():
signal.signal(signum, handler)
def thaw_after_killed_sync(self, service):
since = service.frozen_since()
if since is None:
return
self.stdout.write("a previous sync left the zones frozen, thawing... ")
summary = (
f"A zone sync killed after {since:%Y-%m-%d %H:%M} left every zone frozen"
)
success, failed_command, output = service.reload()
if not success:
self.stdout.write("fail\n")
self.notify_failure(
f"{summary}, and thawing them failed", failed_command, output, []
)
self.stdout.write("ok\n")
discord.notify(
subject="⚠️ Zones thawed after a killed zone sync",
message=f"{summary}, refusing dynamic updates until now. "
"They are reloaded and thawed.",
)
def reload_after_interruption(self, service, zones, error):
reason = str(error) if isinstance(error, Interrupted) else repr(error)
written = [z for z, c in service.changes.items() if c["success"]]
not_written = [z.domain_name for z in zones if z.domain_name not in written]
self.stdout.write(f"stopped by {reason}, reloading zones... ")
success, failed_command, output = service.reload()
if not success:
self.stdout.write("fail\n")
self.notify_failure(
f"Zone sync stopped by {reason}, and reloading the zones failed; "
"they may still be frozen",
failed_command,
output,
not_written,
)
self.stdout.write("ok\n")
message = f"Zone sync stopped by {reason}. The zones are reloaded and thawed."
if written:
message += "\n\nWritten:\n" + "\n".join("- " + z for z in written)
if not_written:
message += "\n\nNot written, run mdb-sync again:\n" + "\n".join(
"- " + z for z in not_written
)
discord.notify(subject="⚠️ Zone sync interrupted, zones thawed", message=message)
def freeze(self, service, zones):
self.stdout.write("freezing zones... ")
success, failed_command, output = service.freeze()
if not success:
self.stdout.write("fail\n")
# A failed freeze may have frozen some zones before stopping.
service.thaw()
self.notify_failure(
"Failed to freeze zones, nothing was written",
failed_command,
output,
[z.domain_name for z in zones],
)
self.stdout.write("ok\n")
def reload_and_notify(self, service, changes):
self.stdout.write("reloading zones... ")
success, failed_command, output = service.reload()
if not success:
self.stdout.write("fail\n")
errors = "\n\n\n".join(
[v["errors"] for v in changes.values() if v["errors"]]
)
diffs = "\n\n\n".join([v["diff"] for v in changes.values() if v["diff"]])
self.notify_failure(
"Failed to reload zones",
failed_command,
output,
list(changes.keys()),
errors,
diffs,
)
self.stdout.write("ok\n")
for zone, info in changes.items():
message = f"Successfully updated bind zone file for `{zone}`"
if info["errors"]:
message += "\n\n" + info["errors"]
discord.notify(
subject="✅ Success: bind",
message=message,
preformatted=info["diff"] or None,
)
def notify_failure(
self, summary, failed_command, output, zones, errors="", diffs=""
):
zone_list = "\n".join("- " + z for z in zones)
message = f"{summary}, please inspect...\n\n{getpass.getuser()}@{os.uname()[1]}$ {failed_command}\n{output}\n{zone_list}"
if errors:
message += "\n\n" + errors
discord.notify(
subject="⚠️ FAIL: bind",
message=message,
preformatted=diffs or None,
)
raise SystemExit(1)
+16 -18
View File
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
import re import re
import django.core.validators import django.core.validators
@@ -81,7 +79,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=256)), ('name', models.CharField(max_length=256)),
('target', models.CharField(max_length=256)), ('target', models.CharField(max_length=256)),
('created_date', models.DateTimeField(auto_now_add=True)), ('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain')), ('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
], ],
options={ options={
}, },
@@ -94,7 +92,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=256)), ('name', models.CharField(max_length=256)),
('target', models.CharField(max_length=256)), ('target', models.CharField(max_length=256)),
('created_date', models.DateTimeField(auto_now_add=True)), ('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain')), ('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
], ],
options={ options={
}, },
@@ -112,7 +110,7 @@ class Migration(migrations.Migration):
('port', models.IntegerField()), ('port', models.IntegerField()),
('target', models.CharField(max_length=256)), ('target', models.CharField(max_length=256)),
('created_date', models.DateTimeField(auto_now_add=True)), ('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain')), ('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
], ],
options={ options={
}, },
@@ -125,7 +123,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=256)), ('name', models.CharField(max_length=256)),
('target', models.CharField(max_length=256)), ('target', models.CharField(max_length=256)),
('created_date', models.DateTimeField(auto_now_add=True)), ('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain')), ('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
], ],
options={ options={
}, },
@@ -174,8 +172,8 @@ class Migration(migrations.Migration):
('pxe_filename', models.CharField(max_length=64, blank=True)), ('pxe_filename', models.CharField(max_length=64, blank=True)),
('dhcp_client', models.BooleanField(default=False)), ('dhcp_client', models.BooleanField(default=False)),
('created_date', models.DateTimeField(auto_now_add=True)), ('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain')), ('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
('host', models.ForeignKey(to='mdb.Host')), ('host', models.ForeignKey(to='mdb.Host', on_delete=models.CASCADE)),
], ],
options={ options={
}, },
@@ -217,7 +215,7 @@ class Migration(migrations.Migration):
('dhcp_dynamic', models.BooleanField(default=False)), ('dhcp_dynamic', models.BooleanField(default=False)),
('dhcp_dynamic_start', models.IPAddressField(null=True, blank=True)), ('dhcp_dynamic_start', models.IPAddressField(null=True, blank=True)),
('dhcp_dynamic_end', models.IPAddressField(null=True, blank=True)), ('dhcp_dynamic_end', models.IPAddressField(null=True, blank=True)),
('dhcp_config', models.ForeignKey(to='mdb.DhcpConfig')), ('dhcp_config', models.ForeignKey(to='mdb.DhcpConfig', on_delete=models.CASCADE)),
], ],
options={ options={
'verbose_name': 'IPv4 subnet', 'verbose_name': 'IPv4 subnet',
@@ -229,7 +227,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('address', models.CharField(max_length=64)), ('address', models.CharField(max_length=64)),
('interface', models.ForeignKey(to='mdb.Interface')), ('interface', models.ForeignKey(to='mdb.Interface', on_delete=models.CASCADE)),
], ],
options={ options={
'verbose_name': 'IPv6 address', 'verbose_name': 'IPv6 address',
@@ -309,7 +307,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='operatingsystem', model_name='operatingsystem',
name='architecture', name='architecture',
field=models.ForeignKey(to='mdb.OsArchitecture'), field=models.ForeignKey(to='mdb.OsArchitecture', on_delete=models.CASCADE),
preserve_default=True, preserve_default=True,
), ),
migrations.AddField( migrations.AddField(
@@ -321,7 +319,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='ip6address', model_name='ip6address',
name='subnet', name='subnet',
field=models.ForeignKey(to='mdb.Ip6Subnet'), field=models.ForeignKey(to='mdb.Ip6Subnet', on_delete=models.CASCADE),
preserve_default=True, preserve_default=True,
), ),
migrations.AddField( migrations.AddField(
@@ -333,25 +331,25 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='ip4address', model_name='ip4address',
name='subnet', name='subnet',
field=models.ForeignKey(to='mdb.Ip4Subnet'), field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
preserve_default=True, preserve_default=True,
), ),
migrations.AddField( migrations.AddField(
model_name='interface', model_name='interface',
name='ip4address', name='ip4address',
field=models.ForeignKey(null=True, blank=True, to='mdb.Ip4Address', unique=True), field=models.ForeignKey(null=True, blank=True, to='mdb.Ip4Address', unique=True, on_delete=models.SET_NULL),
preserve_default=True, preserve_default=True,
), ),
migrations.AddField( migrations.AddField(
model_name='host', model_name='host',
name='host_type', name='host_type',
field=models.ForeignKey(to='mdb.HostType'), field=models.ForeignKey(to='mdb.HostType', on_delete=models.CASCADE),
preserve_default=True, preserve_default=True,
), ),
migrations.AddField( migrations.AddField(
model_name='host', model_name='host',
name='operating_system', name='operating_system',
field=models.ForeignKey(to='mdb.OperatingSystem'), field=models.ForeignKey(to='mdb.OperatingSystem', on_delete=models.CASCADE),
preserve_default=True, preserve_default=True,
), ),
migrations.AddField( migrations.AddField(
@@ -369,13 +367,13 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='dhcpoption', model_name='dhcpoption',
name='ip4subnet', name='ip4subnet',
field=models.ForeignKey(to='mdb.Ip4Subnet'), field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
preserve_default=True, preserve_default=True,
), ),
migrations.AddField( migrations.AddField(
model_name='dhcpcustomfield', model_name='dhcpcustomfield',
name='ip4subnet', name='ip4subnet',
field=models.ForeignKey(to='mdb.Ip4Subnet'), field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
preserve_default=True, preserve_default=True,
), ),
] ]
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
import django.core.validators import django.core.validators
import re import re
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
from django.db.migrations import RunPython from django.db.migrations import RunPython
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
+1 -3
View File
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
import django.core.validators import django.core.validators
import re import re
@@ -35,7 +33,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='interface', model_name='interface',
name='ip4address', name='ip4address',
field=models.OneToOneField(to='mdb.Ip4Address', null=True, blank=True), field=models.OneToOneField(to='mdb.Ip4Address', null=True, blank=True, on_delete=models.SET_NULL),
), ),
migrations.AlterField( migrations.AlterField(
model_name='interface', model_name='interface',
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
import re import re
import django.core.validators import django.core.validators
+1 -3
View File
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
@@ -18,7 +16,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=256)), ('name', models.CharField(max_length=256)),
('target', models.GenericIPAddressField(protocol='IPv6')), ('target', models.GenericIPAddressField(protocol='IPv6')),
('created_date', models.DateTimeField(auto_now_add=True)), ('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain')), ('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
], ],
), ),
migrations.AlterField( migrations.AlterField(
+28
View File
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mdb', '0010_auto_20151004_1709'),
]
operations = [
migrations.AddField(
model_name='operatingsystem',
name='pxe_initrd',
field=models.CharField(null=True, blank=True, max_length=255),
),
migrations.AddField(
model_name='operatingsystem',
name='pxe_kernel',
field=models.CharField(null=True, blank=True, max_length=255),
),
migrations.AddField(
model_name='operatingsystem',
name='pxe_preseed_config_url',
field=models.URLField(null=True, blank=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.1.13 on 2025-05-12 03:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mdb', '0011_auto_20160325_1412'),
]
operations = [
migrations.AlterField(
model_name='domaintxtrecord',
name='target',
field=models.CharField(max_length=2048),
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-05-20 21:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mdb', '0012_alter_domaintxtrecord_target'),
]
operations = [
migrations.AddField(
model_name='host',
name='allow_email',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,39 @@
import ipaddress
from django.db import migrations
def set_classless_reverse_names(apps, schema_editor):
"""Recompute domain_name for sub-/24 IPv4 subnets (RFC 2317 classless).
domain_name is frozen on first save, so subnets created before classless
support kept the plain /24 name (190.36.158.in-addr.arpa) even for a /26.
That apex no longer matches the classless zone declared in named.conf, so
BIND refuses to load the file. Rewrite those names to the RFC 2317 form
(192-26.190.36.158.in-addr.arpa). /24-or-shorter subnets are left alone.
"""
Ip4Subnet = apps.get_model("mdb", "Ip4Subnet")
for subnet in Ip4Subnet.objects.all():
try:
net = ipaddress.IPv4Network(
"%s/%s" % (subnet.network, subnet.netmask), strict=False
)
except ValueError:
continue
if net.prefixlen <= 24:
continue
o1, o2, o3, o4 = str(net.network_address).split(".")
name = "%s-%d.%s.%s.%s.in-addr.arpa" % (o4, net.prefixlen, o3, o2, o1)
if subnet.domain_name != name:
subnet.domain_name = name
subnet.save(update_fields=["domain_name"])
class Migration(migrations.Migration):
dependencies = [
("mdb", "0013_host_allow_email"),
]
operations = [
migrations.RunPython(set_classless_reverse_names, migrations.RunPython.noop),
]
+25
View File
@@ -0,0 +1,25 @@
# Generated by Django 6.1.2 on 2026-11-04 20:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mdb', '0014_classless_reverse_zone_names'),
]
operations = [
migrations.RemoveField(
model_name='operatingsystem',
name='pxe_initrd',
),
migrations.RemoveField(
model_name='operatingsystem',
name='pxe_kernel',
),
migrations.RemoveField(
model_name='operatingsystem',
name='pxe_preseed_config_url',
),
]
+209 -246
View File
@@ -1,17 +1,21 @@
import datetime
import ipaddress
from django.db import models from django.db import models
from django.db.models import Count from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from mdb.utils import host_as_pxe_files
from mdb.validators import validate_hostname, validate_macaddr from mdb.validators import validate_hostname, validate_macaddr
import ipaddress # The boot file of a PXE installable host. UEFI x86-64 clients, PXE architecture 7 or 9
import datetime # in their vendor class, get the UEFI loader instead.
PXE_FILENAME = "pxelinux.0"
PXE_EFI_FILENAME = "grubx64.efi"
PXE_EFI_CLIENT = " or ".join(
f'substring(option vendor-class-identifier, 0, 20) = "PXEClient:Arch:{arch}"'
for arch in ("00007", "00009")
)
@python_2_unicode_compatible
class Nameserver(models.Model): class Nameserver(models.Model):
hostname = models.CharField(max_length=256) hostname = models.CharField(max_length=256)
@@ -19,7 +23,6 @@ class Nameserver(models.Model):
return self.hostname return self.hostname
@python_2_unicode_compatible
class MailExchange(models.Model): class MailExchange(models.Model):
priority = models.IntegerField() priority = models.IntegerField()
hostname = models.CharField(max_length=256) hostname = models.CharField(max_length=256)
@@ -28,7 +31,6 @@ class MailExchange(models.Model):
return "(" + str(self.priority) + ") " + self.hostname return "(" + str(self.priority) + ") " + self.hostname
@python_2_unicode_compatible
class Domain(models.Model): class Domain(models.Model):
domain_name = models.CharField(max_length=256) domain_name = models.CharField(max_length=256)
domain_soa = models.CharField(max_length=256) domain_soa = models.CharField(max_length=256)
@@ -40,8 +42,10 @@ class Domain(models.Model):
domain_expire = models.IntegerField(default=604800) domain_expire = models.IntegerField(default=604800)
domain_minimum_ttl = models.IntegerField(default=86400) domain_minimum_ttl = models.IntegerField(default=86400)
domain_admin = models.EmailField() domain_admin = models.EmailField()
domain_ipaddr = models.GenericIPAddressField(protocol='IPv4') domain_ipaddr = models.GenericIPAddressField(protocol="IPv4")
domain_ip6addr = models.GenericIPAddressField(protocol='IPv6', blank=True, null=True) domain_ip6addr = models.GenericIPAddressField(
protocol="IPv6", blank=True, null=True
)
domain_filename = models.CharField(max_length=256) domain_filename = models.CharField(max_length=256)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
@@ -51,54 +55,40 @@ class Domain(models.Model):
def __str__(self): def __str__(self):
return self.domain_name return self.domain_name
def domain_admin_formatted(self):
return self.domain_admin.replace("@", ".")
def num_records(self):
host_a_records = Host.objects.filter(interface__domain=self).count()
size = {
"CNAME": self.domaincnamerecord_set.count(),
"SRV": self.domainsrvrecord_set.count(),
"TXT": self.domaintxtrecord_set.count(),
"A": self.domainarecord_set.count() + host_a_records
}
return ', '.join(['{}: {}'.format(k, v) for k, v in size.items()])
num_records.short_description = "Num Records"
def __eq__(self, other): def __eq__(self, other):
if not other or not hasattr(other, 'domain_name'): if not other or not hasattr(other, "domain_name"):
return False return False
return self.domain_name == other.domain_name return self.domain_name == other.domain_name
def zone_file_contents(self): def zone_file_contents(self):
content = "; serial:%d\n" % self.domain_serial content = f"; serial:{self.domain_serial:d}\n"
content += "; zone file for %s\n" % self.domain_name content += f"; zone file for {self.domain_name}\n"
content += "; %s\n" % datetime.datetime.now() content += f"; {datetime.datetime.now()}\n"
content += "; filename: %s\n" % self.domain_filename content += f"; filename: {self.domain_filename}\n"
content += "$TTL %s\n" % self.domain_ttl content += f"$TTL {self.domain_ttl}\n"
content += "@ IN SOA %s. %s. (\n" % ( content += "@ IN SOA {}. {}. (\n".format(
self.domain_soa, self.domain_soa,
self.domain_admin.replace("@", ".")) self.domain_admin.replace("@", "."),
content += "\t%d\t; serial\n" % self.domain_serial )
content += "\t%d\t; refresh\n" % self.domain_refresh content += f"\t{self.domain_serial:d}\t; serial\n"
content += "\t%d\t; retry\n" % self.domain_retry content += f"\t{self.domain_refresh:d}\t; refresh\n"
content += "\t%d\t; expire\n" % self.domain_expire content += f"\t{self.domain_retry:d}\t; retry\n"
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl content += f"\t{self.domain_expire:d}\t; expire\n"
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
content += ";\n" content += ";\n"
for nameserver in self.domain_nameservers.all(): for nameserver in self.domain_nameservers.all():
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
for mx in self.domain_mailexchanges.all(): for mx in self.domain_mailexchanges.all():
content += "@\t\tMX\t%d %s.\n" % (mx.priority, mx.hostname) content += f"@\t\tMX\t{mx.priority:d} {mx.hostname}.\n"
if self.domain_ipaddr is not None: if self.domain_ipaddr is not None:
content += "@\tIN\tA\t%s\n" % self.domain_ipaddr content += f"@\tIN\tA\t{self.domain_ipaddr}\n"
if self.domain_ip6addr is not None: if self.domain_ip6addr:
content += "@\tIN\tAAAA\t%s\n" % self.domain_ip6addr content += f"@\tIN\tAAAA\t{self.domain_ip6addr}\n"
content += "; SRV records\n" content += "; SRV records\n"
@@ -118,7 +108,7 @@ class Domain(models.Model):
content += "; CNAME records \n" content += "; CNAME records \n"
for cname in self.domaincnamerecord_set.all(): for cname in self.domaincnamerecord_set.all():
content += "%s\tIN\tCNAME\t%s\n" % (cname.name, cname.target) content += f"{cname.name}\tIN\tCNAME\t{cname.target}\n"
content += "; TXT records \n" content += "; TXT records \n"
@@ -130,25 +120,21 @@ class Domain(models.Model):
for interface in self.interface_set.all(): for interface in self.interface_set.all():
host = interface.host host = interface.host
if interface.ip4address: if interface.ip4address:
content += "%-20s\tIN\tA\t%s\n" % ( content += (
host.hostname, f"{host.hostname:<20}\tIN\tA\t{interface.ip4address.address}\n"
interface.ip4address.address) )
for ipv6addr in interface.ip6address_set.all(): for ipv6addr in interface.ip6address_set.all():
content += "%-20s\tIN\tAAAA\t%s\n" % ( content += f"{host.hostname:<20}\tIN\tAAAA\t{ipv6addr.full_address()}\n"
host.hostname,
ipv6addr.full_address()) if host.allow_email and interface.ip4address:
content += f'{host.hostname}\tTXT\t"v=spf1 mx ip4:{interface.ip4address.address} -all"\n'
elif host.allow_email:
content += f'{host.hostname}\tTXT\t"v=spf1 mx -all"\n'
return content return content
def zone_file_contents_alt(self):
context = {
'domain': self,
'now': datetime.datetime.now()
}
return render_to_string('zone_file.txt', context=context)
@python_2_unicode_compatible
class DomainSrvRecord(models.Model): class DomainSrvRecord(models.Model):
srvce = models.CharField(max_length=64) srvce = models.CharField(max_length=64)
prot = models.CharField(max_length=64) prot = models.CharField(max_length=64)
@@ -157,82 +143,71 @@ class DomainSrvRecord(models.Model):
weight = models.IntegerField() weight = models.IntegerField()
port = models.IntegerField() port = models.IntegerField()
target = models.CharField(max_length=256) target = models.CharField(max_length=256)
domain = models.ForeignKey(Domain) domain = models.ForeignKey(Domain, models.CASCADE)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
def as_record(self): def as_record(self):
return '{}.{}.{}. IN SRV {} {} {} {}.'.format( return f"{self.srvce}.{self.prot}.{self.name}. IN SRV {self.priority} {self.weight} {self.port} {self.target}."
self.srvce,
self.prot,
self.name,
self.priority,
self.weight,
self.port,
self.target
)
def __str__(self): def __str__(self):
return self.as_record() return self.as_record()
@python_2_unicode_compatible
class DomainTxtRecord(models.Model): class DomainTxtRecord(models.Model):
name = models.CharField(max_length=256) name = models.CharField(max_length=256)
target = models.CharField(max_length=256) # note that the max length of a TXT record is 255, but
domain = models.ForeignKey(Domain) # bind allows for longer records by splitting them
# into chunks as: "...255..." "...another 255..."
target = models.CharField(max_length=2048)
domain = models.ForeignKey(Domain, models.CASCADE)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
def as_record(self): def as_record(self):
return '{} TXT {}'.format(self.name, self.target) return f"{self.name} TXT {self.target}"
def __str__(self): def __str__(self):
return self.as_record() return self.as_record()
@python_2_unicode_compatible
class DomainCnameRecord(models.Model): class DomainCnameRecord(models.Model):
name = models.CharField(max_length=256) name = models.CharField(max_length=256)
target = models.CharField(max_length=256) target = models.CharField(max_length=256)
domain = models.ForeignKey(Domain) domain = models.ForeignKey(Domain, models.CASCADE)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
def as_record(self): def as_record(self):
return '{} IN CNAME {}'.format(self.name, self.target) return f"{self.name} IN CNAME {self.target}"
def __str__(self): def __str__(self):
return self.as_record() return self.as_record()
@python_2_unicode_compatible
class DomainARecord(models.Model): class DomainARecord(models.Model):
name = models.CharField(max_length=256) name = models.CharField(max_length=256)
target = models.GenericIPAddressField(protocol='IPv4') target = models.GenericIPAddressField(protocol="IPv4")
domain = models.ForeignKey(Domain) domain = models.ForeignKey(Domain, models.CASCADE)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
def as_record(self): def as_record(self):
return '{} IN A {}'.format(self.name, self.target) return f"{self.name} IN A {self.target}"
def __str__(self): def __str__(self):
return self.as_record() return self.as_record()
@python_2_unicode_compatible
class DomainAAAARecord(models.Model): class DomainAAAARecord(models.Model):
name = models.CharField(max_length=256) name = models.CharField(max_length=256)
target = models.GenericIPAddressField(protocol='IPv6') target = models.GenericIPAddressField(protocol="IPv6")
domain = models.ForeignKey(Domain) domain = models.ForeignKey(Domain, models.CASCADE)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
def as_record(self): def as_record(self):
return '{} IN AAAA {}'.format(self.name, self.target) return f"{self.name} IN AAAA {self.target}"
def __str__(self): def __str__(self):
return self.as_record() return self.as_record()
@python_2_unicode_compatible
class DhcpConfig(models.Model): class DhcpConfig(models.Model):
serial = models.IntegerField() serial = models.IntegerField()
active_serial = models.IntegerField() active_serial = models.IntegerField()
@@ -244,45 +219,56 @@ class DhcpConfig(models.Model):
log_facility = models.CharField(max_length=255) log_facility = models.CharField(max_length=255)
def dhcpd_configuration(self): def dhcpd_configuration(self):
content = "# Autogenerated configuration %s\n" % datetime.datetime.now() content = f"# Autogenerated configuration {datetime.datetime.now()}\n"
if self.authoritative: if self.authoritative:
content += "authoritative;\n" content += "authoritative;\n"
content += "default-lease-time %d;\n" % self.default_lease_time content += f"default-lease-time {self.default_lease_time:d};\n"
content += "max-lease-time %d;\n" % self.max_lease_time content += f"max-lease-time {self.max_lease_time:d};\n"
content += "log-facility %s;\n" % self.log_facility content += f"log-facility {self.log_facility};\n"
content += "ddns-update-style %s;\n" % self.ddns_update_style content += f"ddns-update-style {self.ddns_update_style};\n"
# time to write the subnet definitions # time to write the subnet definitions
for subnet in self.ip4subnet_set.all(): for subnet in self.ip4subnet_set.all():
content += "\n# %s\n" % subnet.name content += f"\n# {subnet.name}\n"
content += "subnet %s netmask %s {\n" % ( content += f"subnet {subnet.network} netmask {subnet.netmask} {{\n"
subnet.network,
subnet.netmask)
for option in subnet.dhcpoption_set.all(): for option in subnet.dhcpoption_set.all():
content += "\toption %s %s;\n" % (option.key, option.value) content += f"\toption {option.key} {option.value};\n"
for option in subnet.dhcpcustomfield_set.all(): for option in subnet.dhcpcustomfield_set.all():
content += "\t%s;\n" % option.value content += f"\t{option.value};\n"
if subnet.dhcp_dynamic: if subnet.dhcp_dynamic:
content += "\trange %s %s;\n" \ content += (
% (subnet.dhcp_dynamic_start, subnet.dhcp_dynamic_end) f"\trange {subnet.dhcp_dynamic_start} {subnet.dhcp_dynamic_end};\n"
)
content += "}\n" content += "}\n"
# time to write host definitions # time to write host definitions
for subnet in self.ip4subnet_set.all(): for subnet in self.ip4subnet_set.all():
for ip4address in subnet.ip4address_set.all(): for ip4address in subnet.ip4address_set.all():
if not hasattr(ip4address, 'interface') or not ip4address.interface.dhcp_client: if (
not hasattr(ip4address, "interface")
or not ip4address.interface.dhcp_client
):
continue continue
_if = ip4address.interface _if = ip4address.interface
content += "\nhost %s {\n" % _if.host.hostname content += f"\nhost {_if.host.hostname} {{\n"
content += "\thardware ethernet %s;\n" % _if.macaddr content += f"\thardware ethernet {_if.macaddr};\n"
content += "\tfixed-address %s.%s;\n" % \ content += (
(_if.host.hostname, _if.domain.domain_name) f"\tfixed-address {_if.host.hostname}.{_if.domain.domain_name};\n"
if len(_if.pxe_filename) > 0: )
content += "\tfilename \"%s\";\n" % _if.pxe_filename if _if.pxe_filename == PXE_FILENAME:
content += (
f"\tif {PXE_EFI_CLIENT} {{\n"
f'\t\tfilename "{PXE_EFI_FILENAME}";\n'
"\t} else {\n"
f'\t\tfilename "{PXE_FILENAME}";\n'
"\t}\n"
)
elif _if.pxe_filename:
content += f'\tfilename "{_if.pxe_filename}";\n'
content += "}\n" content += "}\n"
return content return content
@@ -291,10 +277,9 @@ class DhcpConfig(models.Model):
return self.name return self.name
class Meta: class Meta:
verbose_name = 'DHCP config' verbose_name = "DHCP config"
@python_2_unicode_compatible
class Ip6Subnet(models.Model): class Ip6Subnet(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
network = models.CharField(max_length=255) network = models.CharField(max_length=255)
@@ -316,25 +301,38 @@ class Ip6Subnet(models.Model):
def __str__(self): def __str__(self):
return self.network + " (" + self.name + ")" return self.network + " (" + self.name + ")"
@property
def reverse_zone_name(self):
"""The ip6.arpa reverse zone name for this subnet's prefix.
One label per nibble, derived from ``netmask`` (the prefix length).
Delegation in ip6.arpa happens on nibble (4-bit) boundaries, so the
prefix is truncated down to whole nibbles.
"""
network = ipaddress.IPv6Address(f"{self.network}::")
nibbles = self.netmask // 4
return ".".join(network.exploded.replace(":", "")[:nibbles])[::-1] + ".ip6.arpa"
def zone_file_contents(self, generate_unassigned=False): def zone_file_contents(self, generate_unassigned=False):
content = "" content = ""
content += "; zone file for %s\n" % self.domain_name content += f"; zone file for {self.domain_name}\n"
content += "; %s\n" % datetime.datetime.now() content += f"; {datetime.datetime.now()}\n"
content += "; filename: %s\n" % self.domain_filename content += f"; filename: {self.domain_filename}\n"
content += "$TTL %s\n" % self.domain_ttl content += f"$TTL {self.domain_ttl}\n"
content += "@ IN SOA %s. %s. (\n" % ( content += "@ IN SOA {}. {}. (\n".format(
self.domain_soa, self.domain_soa,
self.domain_admin.replace("@", ".")) self.domain_admin.replace("@", "."),
content += "\t%d\t; serial\n" % self.domain_serial )
content += "\t%d\t; refresh\n" % self.domain_refresh content += f"\t{self.domain_serial:d}\t; serial\n"
content += "\t%d\t; retry\n" % self.domain_retry content += f"\t{self.domain_refresh:d}\t; refresh\n"
content += "\t%d\t; expire\n" % self.domain_expire content += f"\t{self.domain_retry:d}\t; retry\n"
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl content += f"\t{self.domain_expire:d}\t; expire\n"
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
content += ";\n" content += ";\n"
content += ";\n" content += ";\n"
for nameserver in self.domain_nameservers.all(): for nameserver in self.domain_nameservers.all():
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
content += ";\n" content += ";\n"
@@ -347,13 +345,13 @@ class Ip6Subnet(models.Model):
if addr.interface.domain is None: if addr.interface.domain is None:
continue continue
hostname = "%s.%s" % ( hostname = (
addr.interface.host.hostname, f"{addr.interface.host.hostname}.{addr.interface.domain.domain_name}"
addr.interface.domain.domain_name) )
ip = ipaddress.IPv6Address(self.network + addr.address) ip = ipaddress.IPv6Address(self.network + addr.address)
ip = ".".join(ip.exploded.replace(":", "")[16:])[::-1] ip = ".".join(ip.exploded.replace(":", "")[16:])[::-1]
content += "%s\tPTR\t%s.\n" % (ip, hostname) content += f"{ip}\tPTR\t{hostname}.\n"
# addr = ipaddr.IPv6Address("%s%s" % (interface. # addr = ipaddr.IPv6Address("%s%s" % (interface.
# content += "%20s\tIN\tPTR\t%s.\n" % (addr.address.split(".")[3], hostname) # content += "%20s\tIN\tPTR\t%s.\n" % (addr.address.split(".")[3], hostname)
@@ -361,14 +359,13 @@ class Ip6Subnet(models.Model):
return content return content
class Meta: class Meta:
verbose_name = 'IPv6 subnet' verbose_name = "IPv6 subnet"
@python_2_unicode_compatible
class Ip4Subnet(models.Model): class Ip4Subnet(models.Model):
name = models.CharField(max_length=256) name = models.CharField(max_length=256)
netmask = models.GenericIPAddressField(protocol='IPv4') netmask = models.GenericIPAddressField(protocol="IPv4")
network = models.GenericIPAddressField(protocol='IPv4') network = models.GenericIPAddressField(protocol="IPv4")
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
domain_name = models.CharField(max_length=255, editable=False) domain_name = models.CharField(max_length=255, editable=False)
domain_nameservers = models.ManyToManyField(Nameserver) domain_nameservers = models.ManyToManyField(Nameserver)
@@ -384,122 +381,109 @@ class Ip4Subnet(models.Model):
domain_filename = models.CharField(max_length=256) domain_filename = models.CharField(max_length=256)
dhcp_dynamic = models.BooleanField(default=False) dhcp_dynamic = models.BooleanField(default=False)
dhcp_dynamic_start = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True) dhcp_dynamic_start = models.GenericIPAddressField(
dhcp_dynamic_end = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True) protocol="IPv4", null=True, blank=True
dhcp_config = models.ForeignKey(DhcpConfig) )
dhcp_dynamic_end = models.GenericIPAddressField(
protocol="IPv4", null=True, blank=True
)
dhcp_config = models.ForeignKey(DhcpConfig, models.CASCADE)
def __str__(self): def __str__(self):
return self.network + " (" + self.name + ")" return self.network + " (" + self.name + ")"
def num_addresses(self): @property
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask) def reverse_zone_name(self):
return subnet.num_addresses """The in-addr.arpa reverse zone name for this subnet.
def broadcast_address(self): Masks of /24 or shorter give the standard octet-boundary name
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask) (190.36.158.in-addr.arpa). Masks longer than /24 give the RFC 2317
return subnet.broadcast_address classless name for the sub-/24 block (192-26.190.36.158.in-addr.arpa).
This is the apex BIND expects -- it must match the zone name in
def first_address(self): named.conf or rndc refuses to load the file.
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask) """
return next(subnet.hosts()) net = ipaddress.IPv4Network(f"{self.network}/{self.netmask}", strict=False)
o1, o2, o3, o4 = str(net.network_address).split(".")
def last_address(self): rev = f"{o3}.{o2}.{o1}"
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask) if net.prefixlen > 24:
return list(subnet.hosts())[-1] return f"{o4}-{net.prefixlen:d}.{rev}.in-addr.arpa"
return f"{rev}.in-addr.arpa"
broadcast_address.short_description = 'broadcast'
num_addresses.short_description = '#addresses'
first_address.short_description = 'first address'
last_address.short_description = 'last address'
def zone_file_contents(self, generate_unassigned=False): def zone_file_contents(self, generate_unassigned=False):
content = "" content = ""
content += "; zone file for %s\n" % self.domain_name content += f"; zone file for {self.domain_name}\n"
content += "; %s\n" % datetime.datetime.now() content += f"; {datetime.datetime.now()}\n"
content += "; filename: %s\n" % self.domain_filename content += f"; filename: {self.domain_filename}\n"
content += "$TTL %s\n" % self.domain_ttl content += f"$TTL {self.domain_ttl}\n"
content += "@ IN SOA %s. %s. (\n" % ( content += "@ IN SOA {}. {}. (\n".format(
self.domain_soa, self.domain_soa,
self.domain_admin.replace("@", ".")) self.domain_admin.replace("@", "."),
content += "\t%d\t; serial\n" % self.domain_serial )
content += "\t%d\t; refresh\n" % self.domain_refresh content += f"\t{self.domain_serial:d}\t; serial\n"
content += "\t%d\t; retry\n" % self.domain_retry content += f"\t{self.domain_refresh:d}\t; refresh\n"
content += "\t%d\t; expire\n" % self.domain_expire content += f"\t{self.domain_retry:d}\t; retry\n"
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl content += f"\t{self.domain_expire:d}\t; expire\n"
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
content += ";\n" content += ";\n"
content += ";\n" content += ";\n"
for nameserver in self.domain_nameservers.all(): for nameserver in self.domain_nameservers.all():
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
content += ";\n" content += ";\n"
for addr in self.ip4address_set.all(): for addr in self.ip4address_set.all():
""" Get PTR records for each v4 address in subnet """ """ Get PTR records for each v4 address in subnet """
if hasattr(addr, 'interface') and addr.interface.domain: if hasattr(addr, "interface") and addr.interface.domain:
hostname = "%s.%s" % ( hostname = f"{addr.interface.host.hostname}.{addr.interface.domain.domain_name}"
addr.interface.host.hostname, content += f"{addr.address.split('.')[3]:<20}\tIN\tPTR\t{hostname}.\n"
addr.interface.domain.domain_name) elif not hasattr(addr, "interface") and generate_unassigned:
content += "%-20s\tIN\tPTR\t%s.\n" % \ content += "{}\tIN\tPTR\t{}.{}\n".format(
(addr.address.split(".")[3], hostname)
elif not hasattr(addr, 'interface') and generate_unassigned:
content += "%s\tIN\tPTR\t%s.%s\n" % (
addr.address, addr.address,
addr.address.split(".")[3], addr.address.split(".")[3],
"dhcp.neuf.no.") "dhcp.neuf.no.",
)
return content return content
class Meta: class Meta:
verbose_name = 'IPv4 subnet' verbose_name = "IPv4 subnet"
@python_2_unicode_compatible
class DhcpOption(models.Model): class DhcpOption(models.Model):
key = models.CharField(max_length=255) key = models.CharField(max_length=255)
value = models.CharField(max_length=255) value = models.CharField(max_length=255)
ip4subnet = models.ForeignKey(Ip4Subnet) ip4subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
def __str__(self): def __str__(self):
return self.key + " " + self.value return self.key + " " + self.value
class Meta: class Meta:
verbose_name = 'DHCP option' verbose_name = "DHCP option"
class DhcpCustomField(models.Model): class DhcpCustomField(models.Model):
value = models.CharField(max_length=255) value = models.CharField(max_length=255)
ip4subnet = models.ForeignKey(Ip4Subnet) ip4subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
class Meta: class Meta:
verbose_name = 'DHCP custom field' verbose_name = "DHCP custom field"
@python_2_unicode_compatible
class Ip4Address(models.Model): class Ip4Address(models.Model):
subnet = models.ForeignKey(Ip4Subnet) subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
address = models.GenericIPAddressField(protocol='IPv4') address = models.GenericIPAddressField(protocol="IPv4")
last_contact = models.DateTimeField(null=True, blank=True) last_contact = models.DateTimeField(null=True, blank=True)
ping_avg_rtt = models.FloatField(null=True, blank=True) ping_avg_rtt = models.FloatField(null=True, blank=True)
def __str__(self): def __str__(self):
# FIXME: generates 2 SQL queries?
if not hasattr(self, 'interface'):
return self.address return self.address
return "{} ({})".format(self.address, self.interface.host.hostname)
def assigned_to_host(self):
return self.interface.host
assigned_to_host.short_description = "Assigned to Host"
class Meta: class Meta:
verbose_name = 'IPv4 address' verbose_name = "IPv4 address"
verbose_name_plural = 'IPv4 addresses' verbose_name_plural = "IPv4 addresses"
@python_2_unicode_compatible
class HostType(models.Model): class HostType(models.Model):
host_type = models.CharField(max_length=64) host_type = models.CharField(max_length=64)
description = models.CharField(max_length=1024) description = models.CharField(max_length=1024)
@@ -514,22 +498,23 @@ class HostType(models.Model):
ordering = ("host_type",) ordering = ("host_type",)
@python_2_unicode_compatible
class OperatingSystem(models.Model): class OperatingSystem(models.Model):
OS_ARCHITECTURES = ( OS_ARCHITECTURES = (
('mips', _('MIPS')), ("mips", _("MIPS")),
('arm', _('ARM')), ("arm", _("ARM")),
('broadcom', _('Broadcom')), ("broadcom", _("Broadcom")),
('rc32300', _('RC32300')), ("rc32300", _("RC32300")),
('powerpc', _('PowerPC')), ("powerpc", _("PowerPC")),
('powerpc403ga', _('PowerPC403GA')), ("powerpc403ga", _("PowerPC403GA")),
('unknown', _('Unknown')), ("unknown", _("Unknown")),
('x86_64', _('x86-64')), ("x86_64", _("x86-64")),
('i386', _('x86')), ("i386", _("x86")),
) )
name = models.CharField(max_length=256) name = models.CharField(max_length=256)
version = models.CharField(max_length=64) version = models.CharField(max_length=64)
arch = models.CharField(max_length=255, choices=OS_ARCHITECTURES, blank=True, null=True) arch = models.CharField(
max_length=255, choices=OS_ARCHITECTURES, blank=True, null=True
)
def __str__(self): def __str__(self):
return self.name + " " + self.version + " (" + self.arch + ")" return self.name + " " + self.version + " (" + self.arch + ")"
@@ -538,7 +523,6 @@ class OperatingSystem(models.Model):
ordering = ("name", "version") ordering = ("name", "version")
@python_2_unicode_compatible
class Host(models.Model): class Host(models.Model):
location = models.CharField(max_length=1024) location = models.CharField(max_length=1024)
brand = models.CharField(max_length=1024) brand = models.CharField(max_length=1024)
@@ -548,14 +532,19 @@ class Host(models.Model):
serial_number = models.CharField(max_length=256) serial_number = models.CharField(max_length=256)
description = models.CharField(max_length=1024) description = models.CharField(max_length=1024)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
host_type = models.ForeignKey(HostType) host_type = models.ForeignKey(HostType, models.CASCADE) # FIXME: SET_NULL?
virtual = models.BooleanField(default=False) virtual = models.BooleanField(default=False)
operating_system = models.ForeignKey(OperatingSystem) allow_email = models.BooleanField(default=False)
operating_system = models.ForeignKey(
OperatingSystem, models.CASCADE
) # FIXME: SET_NULL?
request_kerberos_principal = models.BooleanField(default=False) request_kerberos_principal = models.BooleanField(default=False)
kerberos_principal_created = models.BooleanField(default=False, editable=False) kerberos_principal_created = models.BooleanField(default=False, editable=False)
kerberos_principal_name = models.CharField(max_length=256, editable=False) kerberos_principal_name = models.CharField(max_length=256, editable=False)
kerberos_principal_created_date = models.DateTimeField(null=True, blank=True, editable=False) kerberos_principal_created_date = models.DateTimeField(
null=True, blank=True, editable=False
)
pxe_key = models.CharField(max_length=254, blank=True) pxe_key = models.CharField(max_length=254, blank=True)
pxe_installable = models.BooleanField(default=False) pxe_installable = models.BooleanField(default=False)
@@ -563,63 +552,37 @@ class Host(models.Model):
def __str__(self): def __str__(self):
return self.hostname return self.hostname
def in_domain(self):
domains = self.interface_set.values_list('domain__domain_name', flat=True)
return ",".join(domains)
in_domain.short_description = "in domains"
def ipv6_enabled(self):
ipv6_ifs = self.interface_set.annotate(num_ipv6=Count('ip6address')).filter(num_ipv6__gt=0)
return ipv6_ifs.exists()
ipv6_enabled.boolean = True
def mac_addresses(self):
addresses = self.interface_set.filter(macaddr__isnull=False).values_list('macaddr', flat=True)
return ", ".join(addresses)
def ip_addresses(self):
addresses = self.interface_set.filter(ip4address__isnull=False).values_list('ip4address__address', flat=True)
return ", ".join(addresses)
def as_pxe_files(self):
return host_as_pxe_files(self)
@python_2_unicode_compatible
class Interface(models.Model): class Interface(models.Model):
name = models.CharField(max_length=128) name = models.CharField(max_length=128)
macaddr = models.CharField(max_length=17, validators=[validate_macaddr]) macaddr = models.CharField(max_length=17, validators=[validate_macaddr])
pxe_filename = models.CharField(max_length=64, blank=True) pxe_filename = models.CharField(max_length=64, blank=True)
dhcp_client = models.BooleanField(default=False) dhcp_client = models.BooleanField(default=False)
host = models.ForeignKey(Host) host = models.ForeignKey(Host, models.CASCADE)
ip4address = models.OneToOneField(Ip4Address, blank=True, null=True) ip4address = models.OneToOneField(
Ip4Address, blank=True, null=True, on_delete=models.SET_NULL
)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
domain = models.ForeignKey(Domain) domain = models.ForeignKey(Domain, models.CASCADE)
def __str__(self): def __str__(self):
return "%s (%s on %s)" % (self.macaddr, self.name, self.host.hostname) return f"{self.macaddr} ({self.name} on {self.host.hostname})"
def ipv6_enabled(self): def ipv6_enabled(self):
return self.ip6address_set.exists() return self.ip6address_set.exists()
@python_2_unicode_compatible
class Ip6Address(models.Model): class Ip6Address(models.Model):
subnet = models.ForeignKey(Ip6Subnet) subnet = models.ForeignKey(Ip6Subnet, models.CASCADE)
address = models.GenericIPAddressField(protocol='IPv6') address = models.GenericIPAddressField(protocol="IPv6")
interface = models.ForeignKey(Interface) interface = models.ForeignKey(Interface, models.CASCADE)
def full_address(self): def full_address(self):
return self.subnet.network + self.address return self.subnet.network + self.address
def __str__(self): def __str__(self):
return "%s (%s on %s)" % ( return f"{self.full_address()} ({self.interface.name} on {self.interface.host.hostname})"
self.full_address(),
self.interface.name,
self.interface.host.hostname)
class Meta: class Meta:
verbose_name = 'IPv6 address' verbose_name = "IPv6 address"
verbose_name_plural = 'IPv6 addresses' verbose_name_plural = "IPv6 addresses"
View File
Whitespace-only changes.
+59
View File
@@ -0,0 +1,59 @@
from difflib import context_diff
from subprocess import getstatusoutput
from mdb.models import DhcpConfig
class DhcpSyncService:
restart_command = "/usr/sbin/service isc-dhcp-server restart"
def __init__(self, force=False):
self.force = force
def get_config(self):
return DhcpConfig.objects.filter(name="default").get()
def needs_update(self, config):
return config.serial != config.active_serial or self.force
def calculate_diff(self, config, new, old):
return "\n".join(
context_diff(
a=old.splitlines(),
b=new.splitlines(),
fromfile=str(config.active_serial),
tofile=str(config.serial),
lineterm="",
)
)
def write_config(self, config, output_path):
"""Write new DHCP config to file and update active_serial.
Returns (old_serial, diff).
"""
try:
with open(output_path) as f:
old = f.read()
except OSError:
old = ""
new = config.dhcpd_configuration()
with open(output_path, "w") as f:
f.write(new)
diff = self.calculate_diff(config, new, old)
old_serial = config.active_serial
config.active_serial = config.serial
config.save()
return old_serial, diff
def restart_service(self):
"""Run the DHCP server restart command.
Returns (success, output).
"""
status, output = getstatusoutput(self.restart_command)
return status == 0, output
+50
View File
@@ -0,0 +1,50 @@
import json
import requests
from django.conf import settings
MAX_CONTENT_LENGTH = 2000
# Seconds; a hung webhook must not hold up the sync that called it.
TIMEOUT = 10
def notify(subject, message, preformatted=None):
"""Send a notification to the configured Discord webhook.
Args:
subject: Bold title line.
message: Plain text body.
preformatted: Optional content shown as a diff code block.
"""
webhook_url = getattr(settings, "DISCORD_NOTIFICATION_WEBHOOK", None)
if not webhook_url:
return
content = f"**{subject}**"
if message:
content += f"\n{message}"
if preformatted:
content += f"\n```diff\n{preformatted}\n```"
if len(content) <= MAX_CONTENT_LENGTH:
_send_json(webhook_url, content)
else:
_send_as_file(webhook_url, subject, content)
def _send_json(url, content):
resp = requests.post(
url, json={"username": "MDB", "content": content}, timeout=TIMEOUT
)
resp.raise_for_status()
def _send_as_file(url, subject, content):
payload = {"username": "MDB", "content": f"**{subject}** *(see attachment)*"}
resp = requests.post(
url,
data={"payload_json": json.dumps(payload)},
files={"files[0]": ("notification.txt", content.encode(), "text/plain")},
timeout=TIMEOUT,
)
resp.raise_for_status()
+247
View File
@@ -0,0 +1,247 @@
import os
import re
import shutil
import stat
import sys
import tempfile
from datetime import datetime
from difflib import context_diff
from itertools import chain
from subprocess import getstatusoutput
from mdb.models import Domain, Ip4Subnet, Ip6Subnet
class ZoneSyncService:
rndc_bin = shutil.which("rndc") or "/usr/sbin/rndc"
freeze_command = f"{rndc_bin} freeze"
reload_command = f"{rndc_bin} reload"
thaw_command = f"{rndc_bin} thaw"
checkzone_bin = shutil.which("named-checkzone") or "/usr/sbin/named-checkzone"
compilezone_bin = shutil.which("named-compilezone") or "/usr/sbin/named-compilezone"
checkzone_dir = "/tmp/mdb-checkzone"
debug_dir = "/tmp/mdb-debug"
def __init__(self, debug=False, force=False):
self.debug = debug
self.force = force
self.changes = {}
def validate_binaries_exist(self):
for f in (self.checkzone_bin, self.compilezone_bin, self.rndc_bin):
if not os.path.isfile(f) and not self.debug:
print(f"ERROR: no such file {f}, exiting...")
sys.exit(1)
def run_commands(self, commands):
"""Execute commands in order, stopping at the first failure.
Returns (success, failed_command, output). On success, failed_command
and output are empty strings.
"""
for command in commands:
status, output = getstatusoutput(command)
if status != 0:
return False, command, output
return True, "", ""
@property
def frozen_marker(self):
"""Exists from just before rndc freeze until the zones are thawed.
A sync killed in between (SIGKILL, the OOM killer) leaves it behind, and the
next sync thaws the zones it finds frozen.
"""
return os.path.join(self.checkzone_dir, "zones-frozen")
def frozen_since(self):
"""When a sync that never thawed froze the zones, or None."""
try:
return datetime.fromtimestamp(os.path.getmtime(self.frozen_marker))
except FileNotFoundError:
return None
def freeze(self):
"""Stop dynamic updates and flush each dynamic zone's journal to its file.
Must run before zone files are written: freeze dumps the zone BIND
holds in memory, which would overwrite a file written earlier.
"""
with open(self.frozen_marker, "w") as f:
f.write(f"{os.getpid()}\n")
return self.run_commands([self.freeze_command])
def thaw(self):
"""Resume dynamic updates, which a frozen zone refuses (ACME dns-01 among them)."""
result = self.run_commands([self.thaw_command])
if result[0]:
try:
os.unlink(self.frozen_marker)
except FileNotFoundError:
pass
return result
def reload(self):
"""Load the written zone files and resume dynamic updates.
Thaws even when the reload fails, and reports the first failure.
"""
reloaded = self.run_commands([self.reload_command])
thawed = self.thaw()
return reloaded if not reloaded[0] else thawed
def find_zone(self, name):
for model in (Domain, Ip4Subnet, Ip6Subnet):
try:
return model.objects.get(domain_name=name)
except model.DoesNotExist:
pass
return None
def pending_zones(self):
return [
zone
for zone in chain(
Domain.objects.all(), Ip4Subnet.objects.all(), Ip6Subnet.objects.all()
)
if zone.domain_serial != zone.domain_active_serial or self.force
]
def sync_zone(self, zone):
zonetype = getattr(zone._meta, "verbose_name", None) or zone._meta.model_name
try:
with open(zone.domain_filename) as f:
old = f.read()
except OSError:
old = ""
# Dynamic updates (ACME dns-01 challenges) raise the serial in the
# frozen file past the one mdb generated. Secondaries ignore a zone
# whose serial does not increase.
live_serial = self.read_serial(old)
if live_serial is not None and live_serial >= zone.domain_serial:
zone.domain_serial = live_serial + 1
self.changes[zone.domain_name] = {
"old_serial": zone.domain_active_serial,
"new_serial": zone.domain_serial,
"success": False,
"errors": None,
"diff": None,
}
print(
f"updating {zonetype} {zone.domain_name} "
f"[{zone.domain_active_serial:d} -> {zone.domain_serial:d}]"
)
# Normalize to BIND's canonical format so the on-disk file and our
# generated content always use the same format.
new = self.normalize_zone(zone, zone.zone_file_contents())
self.changes[zone.domain_name]["diff"] = self.calculate_diff(new, old, zone)
sys.stdout.write("\t- validating... ")
status, output = self.check_zone(zone, new)
if status != 0:
sys.stdout.write("fail\n")
self.changes[zone.domain_name]["errors"] = output
return False
sys.stdout.write("ok\n")
sys.stdout.write("\t- writing zone file... ")
if not self.debug:
zonefile = zone.domain_filename
else:
zonefile = self.debug_dir + zone.domain_filename
path = "/".join(zonefile.split("/")[:-1])
os.path.isdir(path) or os.makedirs(path)
self.write_zone_file(zonefile, new)
if not self.debug:
zone.domain_active_serial = zone.domain_serial
zone.save()
sys.stdout.write("ok\n")
self.changes[zone.domain_name]["success"] = True
return True
def write_zone_file(self, path, contents):
"""Replace the file at path with contents, whole or not at all.
named may read the file at any time, and a write in place could leave it half
written. The new file takes the old one's owner and mode: bind owns the zone
files, and this runs as root.
"""
directory, name = os.path.split(path)
fd, tmp = tempfile.mkstemp(dir=directory or ".", prefix=f".{name}.")
try:
with os.fdopen(fd, "w") as f:
f.write(contents)
f.flush()
os.fsync(f.fileno())
try:
st = os.stat(path)
except FileNotFoundError:
os.chmod(tmp, 0o644)
else:
os.chmod(tmp, stat.S_IMODE(st.st_mode))
if (st.st_uid, st.st_gid) != (os.getuid(), os.getgid()):
os.chown(tmp, st.st_uid, st.st_gid)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except FileNotFoundError:
pass
raise
def read_serial(self, contents):
"""Return the SOA serial of a zone file, or None if there is none."""
match = re.search(r"^\s*(\d+)\s*;\s*serial", contents, re.MULTILINE)
return int(match.group(1)) if match else None
def normalize_zone(self, zone, contents):
"""Convert zone file contents to BIND's canonical format.
Uses named-compilezone to produce the same format that rndc freeze
writes, so subsequent freeze/thaw cycles won't reformat the file and
cause spurious diffs. Falls back to the original contents if
named-compilezone is unavailable or fails.
"""
if not os.path.isfile(self.compilezone_bin):
return contents
with tempfile.NamedTemporaryFile(mode="w", suffix=".zone", delete=False) as f:
f.write(contents)
tmpfile = f.name
try:
cmd = f"{self.compilezone_bin} -f text -F text -s relative -i none -o - {zone.domain_name} {tmpfile}"
status, output = getstatusoutput(cmd + " 2>/dev/null")
if status == 0:
return output
return contents
finally:
os.unlink(tmpfile)
def calculate_diff(self, new, old, zone):
return "\n".join(
context_diff(
a=old.splitlines(),
b=new.splitlines(),
fromfile=str(zone.domain_active_serial) + "-" + zone.domain_name,
tofile=str(zone.domain_serial) + "-" + zone.domain_name,
lineterm="",
)
)
def check_zone(self, zone, contents):
zonefile = self.checkzone_dir + "/" + zone.domain_name
with open(zonefile, "w") as f:
f.write(contents)
return getstatusoutput(f"{self.checkzone_bin} {zone.domain_name} {zonefile}")
+29 -36
View File
@@ -1,12 +1,11 @@
import logging
import os
import uuid
from django.conf import settings
from django.db.models.signals import pre_delete, post_save, pre_save
from django.dispatch import receiver
import ipaddress import ipaddress
import logging
import uuid
from mdb.models import Interface, Host, Ip6Subnet, Ip4Subnet, Ip4Address from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
from mdb.models import PXE_FILENAME, Host, Interface, Ip4Address, Ip4Subnet, Ip6Subnet
from mdb.utils import format_domain_serial_and_add_one from mdb.utils import format_domain_serial_and_add_one
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -34,9 +33,7 @@ def delete_ips_for_subnet(sender, instance, **kwargs):
def set_domain_name_for_subnet(sender, instance, **kwargs): def set_domain_name_for_subnet(sender, instance, **kwargs):
# we assume that the reverse domain_name does not change # we assume that the reverse domain_name does not change
if len(instance.domain_name) == 0: if len(instance.domain_name) == 0:
ipspl = instance.network.split(".") instance.domain_name = instance.reverse_zone_name
rev = "%s.%s.%s" % (ipspl[2], ipspl[1], ipspl[0])
instance.domain_name = "%s.in-addr.arpa" % rev
# update it's own serial # update it's own serial
# if instance.domain_serial is not None: # if instance.domain_serial is not None:
@@ -46,7 +43,9 @@ def set_domain_name_for_subnet(sender, instance, **kwargs):
# when the subnet is changed # when the subnet is changed
if instance.dhcp_config: if instance.dhcp_config:
# instance.dhcp_config.serial = instance.dhcp_config.serial + 1 # instance.dhcp_config.serial = instance.dhcp_config.serial + 1
instance.dhcp_config.serial = format_domain_serial_and_add_one(instance.dhcp_config.serial) instance.dhcp_config.serial = format_domain_serial_and_add_one(
instance.dhcp_config.serial
)
instance.dhcp_config.save() instance.dhcp_config.save()
@@ -55,8 +54,7 @@ def set_domain_name_for_ipv6_subnet(sender, instance, **kwargs):
if len(instance.domain_name) > 0: if len(instance.domain_name) > 0:
return return
network = ipaddress.IPv6Address("%s::" % instance.network) instance.domain_name = instance.reverse_zone_name
instance.domain_name = ".".join(network.exploded.replace(":", "")[:16])[::-1] + ".ip6.arpa"
@receiver(post_save, sender=Interface) @receiver(post_save, sender=Interface)
@@ -77,16 +75,22 @@ def update_domain_serial_when_change_to_host(sender, instance, created, **kwargs
for interface in instance.interface_set.all(): for interface in instance.interface_set.all():
if interface.domain is not None: if interface.domain is not None:
domain = interface.domain domain = interface.domain
domain.domain_serial = format_domain_serial_and_add_one(domain.domain_serial) domain.domain_serial = format_domain_serial_and_add_one(
domain.domain_serial
)
domain.save() domain.save()
if interface.ip4address is not None: if interface.ip4address is not None:
subnet = interface.ip4address.subnet subnet = interface.ip4address.subnet
subnet.domain_serial = format_domain_serial_and_add_one(subnet.domain_serial) subnet.domain_serial = format_domain_serial_and_add_one(
subnet.domain_serial
)
subnet.save() subnet.save()
@receiver(post_save, sender=Host) @receiver(post_save, sender=Host)
def create_pxe_key_and_write_pxe_files_when_host_changes(sender, instance, created, **kwargs): def set_pxe_boot_when_host_changes(sender, instance, created, **kwargs):
"""An installable host netboots into the menu; unticking it stops that.
A filename other than pxelinux's is left alone either way."""
host = instance host = instance
if not host.pxe_key: if not host.pxe_key:
@@ -95,46 +99,35 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(sender, instance, creat
Host.objects.filter(pk=host.pk).update(pxe_key=pxe_key) Host.objects.filter(pk=host.pk).update(pxe_key=pxe_key)
host.pxe_key = pxe_key host.pxe_key = pxe_key
for pxe_file_name, pxe_file in host.as_pxe_files(): for _if in host.interface_set.exclude(ip4address__isnull=True):
path = os.path.join(settings.MDB_PXE_TFTP_ROOT, pxe_file_name)
if host.pxe_installable:
with open(path, 'w+') as f:
f.write(pxe_file)
logger.info("Created or updated {}".format(path))
# Also set necessary fields for pxe installation
interfaces = host.interface_set.exclude(ip4address__isnull=True)
for _if in interfaces:
changed = False changed = False
if host.pxe_installable:
if not _if.pxe_filename: if not _if.pxe_filename:
_if.pxe_filename = 'pxelinux.0' # don't overwrite _if.pxe_filename = PXE_FILENAME
changed = True changed = True
if not _if.dhcp_client: if not _if.dhcp_client:
_if.dhcp_client = True _if.dhcp_client = True
changed = True changed = True
elif _if.pxe_filename == PXE_FILENAME:
_if.pxe_filename = ""
changed = True
if changed: if changed:
_if.save() _if.save()
else:
if os.path.exists(path):
os.unlink(path)
logger.info("deleted {}".format(path))
@receiver(pre_delete, sender=Interface) @receiver(pre_delete, sender=Interface)
def update_domain_serial_when_interface_deleted(sender, instance, **kwargs): def update_domain_serial_when_interface_deleted(sender, instance, **kwargs):
if instance.domain is not None: if instance.domain is not None:
domain = instance.domain domain = instance.domain
domain.domain_serial += 1 domain.domain_serial = format_domain_serial_and_add_one(domain.domain_serial)
domain.save() domain.save()
if instance.ip4address is not None: if instance.ip4address is not None:
subnet = instance.ip4address.subnet subnet = instance.ip4address.subnet
subnet.domain_serial += 1 subnet.domain_serial = format_domain_serial_and_add_one(subnet.domain_serial)
subnet.save() subnet.save()
# @receiver(pre_save, sender=Domain) # @receiver(pre_save, sender=Domain)
# def update_domain_serial_when_domain_is_saved(sender, instance, **kwargs): # def update_domain_serial_when_domain_is_saved(sender, instance, **kwargs):
# instance.domain_serial = format_domain_serial_and_add_one(instance.domain_serial) # instance.domain_serial = format_domain_serial_and_add_one(instance.domain_serial)
-4
View File
@@ -1,4 +0,0 @@
kernel {{kernel}}
append ramdisk_size=14984 locale=en_US console-setup/ask_detect=false keyboard-configuration/layoutcode=no netcfg/wireless_wep= netcfg_choose_interface=eth0 netcfg/get_hostname= preseed/url={{preseed_config_url}} vga=normal initrd={{initrd}} -- snop={{host.pxe_key}}
prompt 0
timeout 0
-31
View File
@@ -1,31 +0,0 @@
; serial: {{ domain.domain_serial }}
; zone file for {{ domain.domain_name }}
; {{ now|date:"c" }}
; filename: {{ domain.domain_filename }}
$TTL {{ domain.domain_ttl }}
@ IN SOA {{ domain.domain_soa }}. {{ domain.domain_admin_formatted }}. (
{{ domain.domain_serial|stringformat:"-10s" }} ; serial
{{ domain.domain_refresh|stringformat:"-10s" }} ; refresh
{{ domain.domain_retry|stringformat:"-10s" }} ; retry
{{ domain.domain_expire|stringformat:"-10s" }} ; expire
{{ domain.domain_minimum_ttl }} ) ; minimum ttl
;
{% for nameserver in domain.domain_nameservers.all %}@ IN NS {{ nameserver.hostname }}.
{% endfor %}{% for mx in domain.domain_mailexchanges.all %}@ MX {{ mx.priority }} {{ mx.hostname }}.{% endfor %}
{% if domain.domain_ipaddr %}@ IN A {{ domain.domain_ipaddr }}{% endif %}
{% if domain.domain_ip6addr %}@ IN AAAA {{ domain.domain_ip6addr }}{% endif %}
; SRV records
{% for srv in domain.domainsrvrecord_set.all %}{{ srv.as_record }}{% endfor %}
; A records
{% for a in domain.domainarecord_set.all %}{{ a.as_record }}{% endfor %}
; AAAA records
{% for aaaa in domain.domainaaaarecord_set.all %}{{ aaaa.as_record }}{% endfor %}
; CNAME records
{% for cname in domain.domaincnamerecord_set.all %}{{ cname.name|stringformat:"-20s" }} IN CNAME {{ cname.target }}{% endfor %}
; TXT records
{% for txt in domain.domaintxtrecord_set.all %}{{ txt.as_record }}{% endfor %}
; HOST records
{% for interface in domain.interface_set.all %}
{% if interface.ip4address %}{{ interface.host.hostname|stringformat:"-20s" }} IN A {{ interface.ip4address.address }}{% endif %}
{% for ipv6addr in interface.ip6address_set.all %}{{ interface.host.hostname|stringformat:"-20s" }} IN AAAA {{ ipv6addr.full_address }}{% endfor %}
{% endfor %}
-56
View File
@@ -1,56 +0,0 @@
import io
from django.contrib.auth.models import User
from django.core.management import call_command
from django.core.urlresolvers import reverse
from django.test import TestCase
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
from mdb.models import Host
class MyAPITestCases(APITestCase):
fixtures = ['test_data']
def create_user_with_token(self):
self.user = User.objects.create(username='admin')
self.token = Token.objects.create(user=self.user)
self.host = Host.objects.first()
def setUp(self):
self.create_user_with_token()
return super(MyAPITestCases, self).setUp()
def test_validate_puppet_host_by_secret(self):
data = {
'pxe_key': self.host.pxe_key,
'certname': '{}.{}'.format(self.host.hostname, self.host.interface_set.first().domain.domain_name)
}
self.client.credentials(HTTP_AUTHORIZATION='Token {}'.format(self.token.key))
res = self.client.post(reverse('api-validate-host-secret'), data, format='json')
self.assertEquals(res.status_code, 200, res.content)
self.assertFalse(Host.objects.get(pk=self.host.pk).pxe_installable)
class UnitTests(TestCase):
fixtures = ['test_data']
def test_host_as_pxe_files(self):
host = Host.objects.first()
self.assertEqual(len(host.as_pxe_files()), 1)
class RunManagementCommands(TestCase):
fixtures = ['test_data']
def test_zone_sync(self):
expected_zone_file = ''
with io.BytesIO() as f:
call_command('zone-sync', debug=True, stdout=f)
# TODO check expected_zone_file
def test_dhcp_sync(self):
expected_dhcp_file = ''
with io.BytesIO() as f:
call_command('dhcp-sync', stdout=f)
# TODO check expected_dhcp_file
+10 -75
View File
@@ -1,81 +1,16 @@
import datetime import datetime
from django.conf import settings
from django.template.loader import render_to_string
import ipaddress
import re
def format_domain_serial_and_add_one(serial): def format_domain_serial_and_add_one(serial):
today = datetime.datetime.now() """Return the serial after `serial`, in the YYYYMMDDnn form where it can be.
res = re.findall("^%4d%02d%02d(\d\d)$" % (
today.year, today.month, today.day), str(serial), re.DOTALL)
if len(res) == 0: The serial must always increase, or named and dhcpd ignore the change. Today's first
""" This probably means that the serial is malformed serial is used while it is higher. Otherwise the serial goes up by one: past nn = 99
or the date is wrong. We assume that if the date is wrong, it carries into the date (2026110299 -> 2026110300), and a serial already ahead of
it is in the past. Just create a new serial starting from 1.""" today, from that carry or a dynamic update, keeps counting from where it is.
return "%4d%02d%02d%02d" % \ """
(today.year, today.month, today.day, 1) today_first = int(datetime.date.today().strftime("%Y%m%d")) * 100 + 1
elif len(res) == 1:
""" The serial contains todays date, just update it. """
try: try:
number = int(res[0]) return max(int(serial) + 1, today_first)
except: except TypeError, ValueError:
number = 1 return today_first
if number >= 99:
""" This is bad... Just keep the number on 99.
We also send a mail to sysadmins telling them that
something is wrong..."""
else:
number += 1
return "%4d%02d%02d%02d" % (
today.year, today.month, today.day, number)
else:
""" Just return the first serial for today. """
return "%4d%02d%02d%02d" % \
(today.year, today.month, today.day, 1)
def render_pxelinux_cfg(_if, host):
# TODO add fields to os model and make dynamic per host
distro = 'ubuntu/14_04/amd64/alternate/'
kernel = distro + 'linux'
initrd = distro + 'initrd.gz'
preseed_config_url = settings.MDB_PXE_PRESEED_URL
context = {
'kernel': kernel,
'initrd': initrd,
'preseed_config_url': preseed_config_url,
'host': host,
'interface': _if
}
return render_to_string('pxelinux.cfg', context=context)
def get_pxe_filename(interface, filename_format='mac_addr'):
"""
Mac or IP address based PXE filename
Ref: http://www.syslinux.org/wiki/index.php/PXELINUX#Examples
"""
ip_addr_in_hex = '{:02X}'.format(int(ipaddress.IPv4Address(interface.ip4address.address)))
formats = {
'ip_addr': ip_addr_in_hex,
'mac_addr': '01-{}'.format(interface.macaddr.lower().replace(':', '-'))
}
return formats.get(filename_format, formats['mac_addr'])
def host_as_pxe_files(host):
""" Returns a list of filename,content for each host interface with an IPv4 address """
from mdb.models import Host
assert isinstance(host, Host)
pxe_files = []
for _if in host.interface_set.exclude(ip4address__isnull=True):
filename = get_pxe_filename(_if)
content = render_pxelinux_cfg(_if, host)
pxe_files.append((filename, content))
return pxe_files
+10 -12
View File
@@ -1,16 +1,14 @@
from __future__ import unicode_literals
from django.core.validators import RegexValidator
import re import re
# http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names from django.core.validators import RegexValidator
hostname_re = re.compile(r'^(?!-)[-a-z0-9]+(?<!-)$', re.IGNORECASE)
validate_hostname = RegexValidator(
regex=hostname_re,
message='Enter a valid hostname',
code='invalid')
macaddr_re = re.compile(r'^([0-9A-F]{2}[:]){5}([0-9A-F]{2})$', re.IGNORECASE) # http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
hostname_re = re.compile(r"^(?!-)[-a-z0-9]+(?<!-)$", re.IGNORECASE)
validate_hostname = RegexValidator(
regex=hostname_re, message="Enter a valid hostname", code="invalid"
)
macaddr_re = re.compile(r"^([0-9A-F]{2}[:]){5}([0-9A-F]{2})$", re.IGNORECASE)
validate_macaddr = RegexValidator( validate_macaddr = RegexValidator(
regex=macaddr_re, regex=macaddr_re, message="Enter a valid MAC address", code="invalid"
message='Enter a valid MAC address', )
code='invalid')
+107 -94
View File
@@ -1,131 +1,144 @@
import os import os
from django.conf.global_settings import TEMPLATE_CONTEXT_PROCESSORS as TCP
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) BASE_DIR = os.path.dirname(os.path.dirname(__file__))
DEBUG = True # Off unless DJANGO_DEBUG says otherwise; mise.toml turns it on for development.
TEMPLATE_DEBUG = DEBUG DEBUG = os.getenv("DJANGO_DEBUG", "").lower() in ("1", "true", "yes")
ADMINS = ( ADMINS = ("sysadmin@example.com",)
('System Administrator', 'sysadmin@example.com'),
)
MANAGERS = ADMINS MANAGERS = ADMINS
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.sqlite3",
'NAME': 'db.sqlite3', "NAME": "db.sqlite3",
} }
} }
TIME_ZONE = 'Europe/Oslo' TIME_ZONE = "Europe/Oslo"
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = "en-us"
SITE_ID = 1 SITE_ID = 1
USE_I18N = True USE_I18N = True
USE_L10N = True
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
MEDIA_URL = '/media/'
ADMIN_MEDIA_PREFIX = '/media/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') MEDIA_ROOT = os.path.join(BASE_DIR, "media")
STATIC_URL = '/static/' MEDIA_URL = "/media/"
# Static
# Additional locations of static files STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
STATICFILES_DIRS = ( STATIC_URL = "/static/"
)
# List of finder classes that know how to find static files in
# various locations.
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder', "django.contrib.staticfiles.finders.FileSystemFinder",
'django.contrib.staticfiles.finders.AppDirectoriesFinder', "django.contrib.staticfiles.finders.AppDirectoriesFinder",
# 'django.contrib.staticfiles.finders.DefaultStorageFinder',
) )
# Make this unique, and don't share it with anybody. # For development only: the repository is public. Production sets its own (MDB_SETTINGS).
SECRET_KEY = '@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl' SECRET_KEY = "@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl"
MIDDLEWARE_CLASSES = ( MIDDLEWARE = (
'django.middleware.common.CommonMiddleware', "django.middleware.security.SecurityMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', # Serves STATIC_ROOT, so production needs nothing in front but a proxy.
'django.middleware.csrf.CsrfViewMiddleware', "whitenoise.middleware.WhiteNoiseMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.middleware.common.CommonMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
) )
ROOT_URLCONF = 'mdbsite.urls' ROOT_URLCONF = "mdbsite.urls"
TEMPLATE_DIRS = ( TEMPLATES = [
# Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". {
# Always use forward slashes, even on Windows. "BACKEND": "django.template.backends.django.DjangoTemplates",
# Don't forget to use absolute paths, not relative paths. "DIRS": [],
) "APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
],
},
},
]
INSTALLED_APPS = ( INSTALLED_APPS = (
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.sites', "django.contrib.sites",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
'suit', "django.contrib.admin",
'django.contrib.admin', "rest_framework",
'rest_framework', "rest_framework.authtoken",
'rest_framework.authtoken', "django_extensions",
"mdb",
'mdb',
) )
EMAIL_SUBJECT_PREFIX = '[MDB] ' EMAIL_SUBJECT_PREFIX = "[MDB] "
TEMPLATE_CONTEXT_PROCESSORS = TCP + ( # Logging
'django.core.context_processors.request', # for django-suit LOGGING = {
) "version": 1,
"disable_existing_loggers": True,
# django-suit configuration "formatters": {
SUIT_CONFIG = { "verbose": {
# header "format": "%(levelname)s %(asctime)s %(module)s "
'ADMIN_NAME': 'MDB', "%(process)d %(thread)d %(message)s"
'HEADER_DATE_FORMAT': 'l, Y-m-d',
'HEADER_TIME_FORMAT': 'H:i',
# forms
# 'SHOW_REQUIRED_ASTERISK': True, # Default True
# 'CONFIRM_UNSAVED_CHANGES': True, # Default True
# menu
'SEARCH_URL': '/admin/mdb/host/',
# 'MENU_ICONS': {
# 'sites': 'icon-leaf',
# 'auth': 'icon-lock',
# },
# 'MENU_OPEN_FIRST_CHILD': True, # Default True
# 'MENU_EXCLUDE': ('auth.group',),
'MENU': (
{'app': 'mdb', 'label': 'MDB', 'url': 'mdb.host'},
{
'label': 'Settings',
'icon': 'icon-cog',
'models': ('auth.user', 'auth.group')
}, },
), },
"handlers": {
# misc "console": {
'LIST_PER_PAGE': 30 "level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose",
} }
},
"loggers": {
"root": {
"level": "WARNING",
"handlers": ["console"],
},
"django.db.backends": {
"level": "ERROR",
"handlers": ["console"],
"propagate": False,
},
},
}
# REST Framework
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
'PAGINATE_BY': 10
} }
MDB_PXE_TFTP_ROOT = '/var/lib/tftpboot/pxelinux/pxelinux.cfg/' # Sentry
MDB_PXE_PRESEED_URL = 'http://158.36.190.194/ubuntu/preseed_1404.cfg' SENTRY_DSN = os.getenv("SENTRY_DSN")
SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONMENT", "dev")
ADMIN_SITE_NAME = "MDB"
# Production's settings live outside the code, in the file MDB_SETTINGS names; the mdb role
# in edb-infra writes it. An error in it stops MDB rather than leaving the defaults above.
if os.getenv("MDB_SETTINGS"):
with open(os.environ["MDB_SETTINGS"]) as f:
exec(compile(f.read(), os.environ["MDB_SETTINGS"], "exec"))
else:
# Development. Only a missing local_settings is skipped: a local_settings whose own
# imports fail must not leave MDB running on the defaults.
try: try:
from .local_settings import * from .local_settings import * # noqa: F403
except ImportError: except ModuleNotFoundError as e:
pass if e.name != "mdbsite.local_settings":
raise
+9 -4
View File
@@ -1,15 +1,20 @@
from django.conf.urls import include, url
from django.contrib import admin from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import path
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from mdb.api.views import HostPXEValidate from mdb.api.views import HostPXEValidate
admin.autodiscover() admin.autodiscover()
urlpatterns = [ urlpatterns = [
url(r'^$', RedirectView.as_view(url='admin/', permanent=True)), path("", RedirectView.as_view(url="admin/", permanent=True)),
url(r'^admin/', include(admin.site.urls)), path("admin/", admin.site.urls),
url(r'^api/validate-host-secret/', HostPXEValidate.as_view(), name='api-validate-host-secret') path(
"api/validate-host-secret/",
HostPXEValidate.as_view(),
name="api-validate-host-secret",
),
] ]
urlpatterns += staticfiles_urlpatterns() urlpatterns += staticfiles_urlpatterns()
+3 -1
View File
@@ -1,5 +1,7 @@
import os import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdb.settings")
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdbsite.settings")
application = get_wsgi_application() application = get_wsgi_application()
+8
View File
@@ -0,0 +1,8 @@
[tools]
python = "3.14"
uv = "latest"
prek = "latest" # git hooks, see prek.toml
[env]
# settings.py leaves DEBUG off unless this says otherwise.
DJANGO_DEBUG = "1"
+32
View File
@@ -0,0 +1,32 @@
# Git hooks, run by prek (https://prek.j178.dev). `prek install` once per clone; CI runs
# them all (.gitea/workflows/ci.yaml).
[[repos]]
repo = "https://github.com/astral-sh/ruff-pre-commit"
rev = "v0.17.3" # keep in sync with ruff in uv.lock
hooks = [
{ id = "ruff-check", args = ["--fix"] },
{ id = "ruff-format" },
]
[[repos]]
repo = "https://github.com/adamchainz/django-upgrade"
rev = "1.33.0"
hooks = [{ id = "django-upgrade", args = ["--target-version", "6.1"] }]
[[repos]]
repo = "https://github.com/pre-commit/pre-commit-hooks"
rev = "v6.0.0"
hooks = [
{ id = "trailing-whitespace" },
{ id = "end-of-file-fixer" },
{ id = "check-merge-conflict" },
{ id = "check-added-large-files" },
{ id = "check-toml" },
{ id = "check-yaml" },
]
[[repos]]
repo = "https://github.com/astral-sh/uv-pre-commit"
rev = "0.13.6"
hooks = [{ id = "uv-lock" }] # uv.lock stays in sync with pyproject.toml
+59
View File
@@ -0,0 +1,59 @@
[project]
name = "mdb"
version = "0.1.0"
description = "Machine Database - A Django app for managing computer equipment"
authors = [
{ name = "EDB", email = "edb@neuf.no" },
]
requires-python = ">=3.14,<3.15"
license = "GPL-2.0-or-later"
dependencies = [
"django>=6.1.2",
"django-extensions>=4.1",
"djangorestframework>=3.18.3",
"gunicorn>=26.2.0",
"requests>=2.34.2",
"sentry-sdk>=2.73.0",
"whitenoise>=6.12,<7",
]
[dependency-groups]
# Only needed on the server (LDAP is configured in production's settings).
# Kept separate so CI can skip building python-ldap from source.
prod = [
"django-auth-ldap>=5.4.0",
]
dev = [
"freezegun>=1.5.5",
"pytest>=9.2.0",
"pytest-django>=4.14.0",
"ruff>=0.17.3,<1.0.0",
]
[tool.uv]
default-groups = ["dev", "prod"]
[tool.ruff]
exclude = ["mdb/migrations/**"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
]
ignore = [
"E501", # line-too-long
]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "mdbsite.settings"
python_files = ["tests/test_*.py"]
# WhiteNoise, since tests run without collectstatic.
filterwarnings = ["ignore:No directory at:UserWarning"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
-7
View File
@@ -1,7 +0,0 @@
Django>=1.8,<1.9
django-auth-ldap
django-extensions==1.5.7
djangorestframework==3.2.4
django-suit==0.2.15
ipaddress==1.0.14
mysqlclient==1.3.6
-8
View File
@@ -1,8 +0,0 @@
To use this scripts, you have to add the projects location to your environment
pythonpath. This is easily archieved by setting an environment variable
like this:
export PYTHONPATH=/opt/projects/:/opt/projects/dns_mdb
It is important that both the path above the project and the project
folder itself are added to the path before the script is run.
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
TMP1=$(bash -c "cd .. ; pwd ; echo \":\" ; cd .. ; pwd")
TMP2=$(echo $TMP1 | sed -e "s/ //g")
echo "Run the following to setup the python environment."
echo "export PYTHONPATH=$TMP2"
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python
# coding: utf-8
import os,sys,datetime,argparse,threading,time,Queue
from commands import getstatusoutput
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
parser.add_argument('-t', '--type', default='all',\
help="Which type of host to operate on")
parser.add_argument('-d', '--debug', action='store_true',\
help="Turn on debugging")
parser.add_argument('--show-types', dest='show_types', action='store_true',\
help='Show available host types')
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
help='Number of threads used for pinging hosts')
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
args = parser.parse_args()
#project_path = '/opt/django/dns_mdb/'
#if project_path not in sys.path:
# sys.path.append(project_path)
from django.core.management import setup_environ
from django.core.mail import mail_admins
from dns_mdb import settings
setup_environ(settings)
from mdb.models import *
PING_CMD='ping -W 1 -c %d -n -q %s | grep rtt | cut -d " " -f4'
queue = Queue.Queue()
class PingThread(threading.Thread):
""" Threaded ping """
def __init__(self, host_queue):
threading.Thread.__init__(self)
self.queue = queue
def run(self):
while True:
host = self.queue.get()
self.do_ping_host(host)
self.queue.task_done()
def do_ping_host(self, host):
interfaces = host.interface_set.all()
for interface in interfaces:
self.do_ping_interface(interface, host)
def do_ping_interface(self, interface, host):
res = self.do_ping_ipaddr(interface.ip4address.address)
if res == None:
if args.debug:
print "%s (%s/%s) : fail " % (host, \
interface.name, interface.ip4address.address)
return
interface.ip4address.last_contact = datetime.datetime.now()
interface.ip4address.ping_avg_rtt = float(res[1])
interface.ip4address.save()
if args.debug:
print "%s (%s/%s) : %s" % (host, interface.name, \
interface.ip4address.address, res)
def do_ping_ipaddr(self, ipaddr):
status, output = getstatusoutput(PING_CMD % (args.num_pings,ipaddr))
if status == 0 and len(output) > 0:
return output.split("/")
else:
return None
def show_host_types():
types = HostType.objects.all()
for t in types:
print "%s (%s)" % (t.host_type, t.description)
if args.show_types:
show_host_types()
sys.exit(0)
if args.type == "all":
hosts = Host.objects.all()
else:
hosts = Host.objects.filter(host_type__host_type = args.type)
# start num_threads threads for pinging
for i in xrange(args.num_threads):
t = PingThread(queue)
t.setDaemon(True)
t.start()
if args.debug:
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
# Add all hosts to the queue. Ping threads
# will pick hosts from this queue.
for host in hosts:
queue.put(host)
# Wait for all threads to finish, eg. queue is empty.
queue.join()
-156
View File
@@ -1,156 +0,0 @@
#!/usr/bin/env python
# coding: utf-8
import os,sys,datetime,argparse,threading,time,Queue
from commands import getstatusoutput
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
parser.add_argument('-t', '--type', default='all',\
help="Which type of host to operate on")
parser.add_argument('-d', '--debug', action='store_true',\
help="Turn on debugging")
parser.add_argument('--show-types', dest='show_types', action='store_true',\
help='Show available host types')
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
help='Number of threads used for pinging hosts')
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
parser.add_argument('--rrd-path', dest='rrd_path', required=True,\
help='Specify the path to the rrd files')
parser.add_argument('--graph-path', dest='graph_path', required=True,\
help="Specify the output directory for the graphs")
args = parser.parse_args()
from django.core.management import setup_environ
from django.core.mail import mail_admins
from dns_mdb import settings
setup_environ(settings)
from mdb.models import *
queue = Queue.Queue()
RRD_GRAPH = "%s " \
"-w 785 -h 120 -a PNG " \
"--slope-mode " \
"--start -86400 --end now " \
"--font DEFAULT:7: "\
"--title \"%s\" " \
"--watermark \"NEUF.NO\" " \
"--vertical-label \"latency(ms)\" " \
"--right-axis-label \"latency(ms)\" " \
"--lower-limit 0 " \
"--right-axis 1:0 " \
"--x-grid MINUTE:10:HOUR:1:MINUTE:120:0:%%R " \
"--alt-y-grid --rigid " \
"DEF:roundtrip=%s:rrd:AVERAGE " \
"LINE1:roundtrip#0000FF:\"latency(ms)\" "
# RRD_GRAPH ARGS: <output_filename.png>, <title>, <rrd>
RRD_DIR = args.rrd_path
RRD_GRAPH_DIR = args.graph_path
RRDTOOL = "/usr/bin/rrdtool"
class RrdService():
def log(self, host, interface, avg_ping):
rrd = self.get_rrd(host, interface)
self.update_rrd(rrd, avg_ping)
def get_rrd(self, host, interface):
return "%s/%s_%s.rrd" % ( RRD_DIR, host.id,\
interface.id)
def get_png(self, host, interface):
return "%s/%s_%s.png" % (RRD_GRAPH_DIR, host.id,\
interface.id)
def update_rrd(self, rrd, avg_ping):
self.create_rrd(rrd)
self.rrd_exec("update", RRD_UPDATE % (rrd, avg_ping))
def create_rrd(self, rrd):
if os.path.isfile(rrd):
return True
if args.debug:
print "Creating rrd: " + rrd
if not self.rrd_exec("create", RRD_CREATE % rrd):
if args.debug:
print "Could not create RRD database."
return False
return True
def create_title(self, host, interface):
# return "%s (%s)" % (host.hostname, unicode(interface))
return unicode(interface)
def create_graph(self, host, interface):
rrd = self.get_rrd(host, interface)
if not os.path.isfile(rrd): return
self.rrd_exec("graph", RRD_GRAPH % (self.get_png(host, interface), \
self.create_title(host, interface), rrd))
def rrd_exec(self, function, rrd_cmd):
cmd = "%s %s %s" % (RRDTOOL, function, rrd_cmd)
if args.debug:
print "RRD_EXEC (%s)" % cmd
if os.system(cmd) != 0:
return False
else:
return True
class WorkerThread(threading.Thread):
""" Threaded ping """
def __init__(self, host_queue, rrd_service):
threading.Thread.__init__(self)
self.queue = queue
self.rrd_service = rrd_service
def run(self):
while True:
host = self.queue.get()
self.do_graph_host(host)
self.queue.task_done()
def do_graph_host(self, host):
interfaces = host.interface_set.all()
for interface in interfaces:
self.rrd_service.create_graph(host, interface)
def show_host_types():
types = HostType.objects.all()
for t in types:
print "%s (%s)" % (t.host_type, t.description)
if args.show_types:
show_host_types()
sys.exit(0)
if args.type == "all":
hosts = Host.objects.all()
else:
hosts = Host.objects.filter(host_type__host_type = args.type)
rrd_service = RrdService()
# start num_threads threads for pinging
for i in xrange(args.num_threads):
t = WorkerThread(queue, rrd_service)
t.setDaemon(True)
t.start()
if args.debug:
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
# Add all hosts to the queue. Ping threads
# will pick hosts from this queue.
for host in hosts:
queue.put(host)
# Wait for all threads to finish, eg. queue is empty.
queue.join()
sys.exit(0)
-151
View File
@@ -1,151 +0,0 @@
#!/usr/bin/env python
# coding: utf-8
import os,sys,datetime,argparse,threading,time,Queue
from commands import getstatusoutput
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
parser.add_argument('-t', '--type', default='all',\
help="Which type of host to operate on")
parser.add_argument('-d', '--debug', action='store_true',\
help="Turn on debugging")
parser.add_argument('--show-types', dest='show_types', action='store_true',\
help='Show available host types')
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
help='Number of threads used for pinging hosts')
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
parser.add_argument('--rrd-path', dest='rrd_path', required=True,\
help='Specify the path to the rrd files')
args = parser.parse_args()
from django.core.management import setup_environ
from django.core.mail import mail_admins
from dns_mdb import settings
setup_environ(settings)
from mdb.models import *
PING_CMD='ping -W 1 -c %d -n -q %s | grep rtt | cut -d " " -f4'
queue = Queue.Queue()
RRD_CREATE = "%s " \
"DS:ttl:GAUGE:600:U:U "\
"RRA:AVERAGE:0.5:1:576 "\
"RRA:AVERAGE:0.5:6:336 "\
"RRA:AVERAGE:0.5:72:124 "\
"RRA:AVERAGE:0.5:288:365"
RRD_UPDATE = "%s --template rtt N:%s"
RRD_DIR = args.rrd_path
RRDTOOL = "/usr/bin/rrdtool"
class RrdService():
def log(self, host, interface, avg_ping):
rrd = self.get_rrd(host, interface)
self.update_rrd(rrd, avg_ping)
def get_rrd(self, host, interface):
return "%s/%s_%s.rrd" % ( RRD_DIR, host.id,\
interface.id)
def update_rrd(self, rrd, avg_ping):
self.create_rrd(rrd)
self.rrd_exec("update", RRD_UPDATE % (rrd, avg_ping))
def create_rrd(self, rrd):
if os.path.isfile(rrd):
return True
if args.debug:
print "Creating rrd: " + rrd
if not self.rrd_exec("create", RRD_CREATE % rrd):
if args.debug:
print "Could not create RRD database."
return False
return True
def rrd_exec(self, function, rrd_cmd):
cmd = "%s %s %s" % (RRDTOOL, function, rrd_cmd)
if args.debug:
print "RRD_EXEC (%s)" % cmd
if os.system(cmd) != 0:
return False
else:
return True
class PingThread(threading.Thread):
""" Threaded ping """
def __init__(self, host_queue, rrd_service):
threading.Thread.__init__(self)
self.queue = queue
self.rrd_service = rrd_service
def run(self):
while True:
host = self.queue.get()
self.do_ping_host(host)
self.queue.task_done()
def do_ping_host(self, host):
interfaces = host.interface_set.all()
for interface in interfaces:
self.do_ping_interface(interface, host)
def do_ping_interface(self, interface, host):
res = self.do_ping_ipaddr(interface.ip4address.address)
if res == None:
if args.debug:
print "%s (%s/%s) : fail " % (host, \
interface.name, interface.ip4address.address)
return
if args.debug:
print "%s (%s/%s) : %s" % (host, interface.name, \
interface.ip4address.address, res)
self.rrd_service.log(host,interface, res[1])
def do_ping_ipaddr(self, ipaddr):
status, output = getstatusoutput(PING_CMD % (args.num_pings,ipaddr))
if status == 0 and len(output) > 0:
return output.split("/")
else:
return None
def show_host_types():
types = HostType.objects.all()
for t in types:
print "%s (%s)" % (t.host_type, t.description)
if args.show_types:
show_host_types()
sys.exit(0)
if args.type == "all":
hosts = Host.objects.all()
else:
hosts = Host.objects.filter(host_type__host_type = args.type)
rrd_service = RrdService()
# start num_threads threads for pinging
for i in xrange(args.num_threads):
t = PingThread(queue, rrd_service)
t.setDaemon(True)
t.start()
if args.debug:
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
# Add all hosts to the queue. Ping threads
# will pick hosts from this queue.
for host in hosts:
queue.put(host)
# Wait for all threads to finish, eg. queue is empty.
queue.join()
sys.exit(0)
-567
View File
@@ -1,567 +0,0 @@
/*!
* Bootstrap Responsive v2.0.0
*
* Copyright 2012 Twitter, Inc
* Licensed under the Apache License v2.0
* http://www.apache.org/licenses/LICENSE-2.0
*
* Designed and built with all the love in the world @twitter by @mdo and @fat.
*/
.hidden {
display: none;
visibility: hidden;
}
@media (max-width: 480px) {
.nav-collapse {
-webkit-transform: translate3d(0, 0, 0);
}
.page-header h1 small {
display: block;
line-height: 18px;
}
input[class*="span"],
select[class*="span"],
textarea[class*="span"],
.uneditable-input {
display: block;
width: 100%;
height: 28px;
/* Make inputs at least the height of their button counterpart */
/* Makes inputs behave like true block-level elements */
-webkit-box-sizing: border-box;
/* Older Webkit */
-moz-box-sizing: border-box;
/* Older FF */
-ms-box-sizing: border-box;
/* IE8 */
box-sizing: border-box;
/* CSS3 spec*/
}
.input-prepend input[class*="span"], .input-append input[class*="span"] {
width: auto;
}
input[type="checkbox"], input[type="radio"] {
border: 1px solid #ccc;
}
.form-horizontal .control-group > label {
float: none;
width: auto;
padding-top: 0;
text-align: left;
}
.form-horizontal .controls {
margin-left: 0;
}
.form-horizontal .control-list {
padding-top: 0;
}
.form-horizontal .form-actions {
padding-left: 10px;
padding-right: 10px;
}
.modal {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
width: auto;
margin: 0;
}
.modal.fade.in {
top: auto;
}
.modal-header .close {
padding: 10px;
margin: -10px;
}
.carousel-caption {
position: static;
}
}
@media (max-width: 768px) {
.container {
width: auto;
padding: 0 20px;
}
.row-fluid {
width: 100%;
}
.row {
margin-left: 0;
}
.row > [class*="span"], .row-fluid > [class*="span"] {
float: none;
display: block;
width: auto;
margin: 0;
}
}
@media (min-width: 768px) and (max-width: 980px) {
.row {
margin-left: -20px;
*zoom: 1;
}
.row:before, .row:after {
display: table;
content: "";
}
.row:after {
clear: both;
}
[class*="span"] {
float: left;
margin-left: 20px;
}
.span1 {
width: 42px;
}
.span2 {
width: 104px;
}
.span3 {
width: 166px;
}
.span4 {
width: 228px;
}
.span5 {
width: 290px;
}
.span6 {
width: 352px;
}
.span7 {
width: 414px;
}
.span8 {
width: 476px;
}
.span9 {
width: 538px;
}
.span10 {
width: 600px;
}
.span11 {
width: 662px;
}
.span12, .container {
width: 724px;
}
.offset1 {
margin-left: 82px;
}
.offset2 {
margin-left: 144px;
}
.offset3 {
margin-left: 206px;
}
.offset4 {
margin-left: 268px;
}
.offset5 {
margin-left: 330px;
}
.offset6 {
margin-left: 392px;
}
.offset7 {
margin-left: 454px;
}
.offset8 {
margin-left: 516px;
}
.offset9 {
margin-left: 578px;
}
.offset10 {
margin-left: 640px;
}
.offset11 {
margin-left: 702px;
}
.row-fluid {
width: 100%;
*zoom: 1;
}
.row-fluid:before, .row-fluid:after {
display: table;
content: "";
}
.row-fluid:after {
clear: both;
}
.row-fluid > [class*="span"] {
float: left;
margin-left: 2.762430939%;
}
.row-fluid > [class*="span"]:first-child {
margin-left: 0;
}
.row-fluid .span1 {
width: 5.801104972%;
}
.row-fluid .span2 {
width: 14.364640883%;
}
.row-fluid .span3 {
width: 22.928176794%;
}
.row-fluid .span4 {
width: 31.491712705%;
}
.row-fluid .span5 {
width: 40.055248616%;
}
.row-fluid .span6 {
width: 48.618784527%;
}
.row-fluid .span7 {
width: 57.182320438000005%;
}
.row-fluid .span8 {
width: 65.74585634900001%;
}
.row-fluid .span9 {
width: 74.30939226%;
}
.row-fluid .span10 {
width: 82.87292817100001%;
}
.row-fluid .span11 {
width: 91.436464082%;
}
.row-fluid .span12 {
width: 99.999999993%;
}
input.span1, textarea.span1, .uneditable-input.span1 {
width: 32px;
}
input.span2, textarea.span2, .uneditable-input.span2 {
width: 94px;
}
input.span3, textarea.span3, .uneditable-input.span3 {
width: 156px;
}
input.span4, textarea.span4, .uneditable-input.span4 {
width: 218px;
}
input.span5, textarea.span5, .uneditable-input.span5 {
width: 280px;
}
input.span6, textarea.span6, .uneditable-input.span6 {
width: 342px;
}
input.span7, textarea.span7, .uneditable-input.span7 {
width: 404px;
}
input.span8, textarea.span8, .uneditable-input.span8 {
width: 466px;
}
input.span9, textarea.span9, .uneditable-input.span9 {
width: 528px;
}
input.span10, textarea.span10, .uneditable-input.span10 {
width: 590px;
}
input.span11, textarea.span11, .uneditable-input.span11 {
width: 652px;
}
input.span12, textarea.span12, .uneditable-input.span12 {
width: 714px;
}
}
@media (max-width: 980px) {
body {
padding-top: 0;
}
.navbar-fixed-top {
position: static;
margin-bottom: 18px;
}
.navbar-fixed-top .navbar-inner {
padding: 5px;
}
.navbar .container {
width: auto;
padding: 0;
}
.navbar .brand {
padding-left: 10px;
padding-right: 10px;
margin: 0 0 0 -5px;
}
.navbar .nav-collapse {
clear: left;
}
.navbar .nav {
float: none;
margin: 0 0 9px;
}
.navbar .nav > li {
float: none;
}
.navbar .nav > li > a {
margin-bottom: 2px;
}
.navbar .nav > .divider-vertical {
display: none;
}
.navbar .nav > li > a, .navbar .dropdown-menu a {
padding: 6px 15px;
font-weight: bold;
color: #999999;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
}
.navbar .dropdown-menu li + li a {
margin-bottom: 2px;
}
.navbar .nav > li > a:hover, .navbar .dropdown-menu a:hover {
background-color: #222222;
}
.navbar .dropdown-menu {
position: static;
top: auto;
left: auto;
float: none;
display: block;
max-width: none;
margin: 0 15px;
padding: 0;
background-color: transparent;
border: none;
-webkit-border-radius: 0;
-moz-border-radius: 0;
border-radius: 0;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
}
.navbar .dropdown-menu:before, .navbar .dropdown-menu:after {
display: none;
}
.navbar .dropdown-menu .divider {
display: none;
}
.navbar-form, .navbar-search {
float: none;
padding: 9px 15px;
margin: 9px 0;
border-top: 1px solid #222222;
border-bottom: 1px solid #222222;
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
-moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
}
.navbar .nav.pull-right {
float: none;
margin-left: 0;
}
.navbar-static .navbar-inner {
padding-left: 10px;
padding-right: 10px;
}
.btn-navbar {
display: block;
}
.nav-collapse {
overflow: hidden;
height: 0;
}
}
@media (min-width: 980px) {
.nav-collapse.collapse {
height: auto !important;
}
}
@media (min-width: 1200px) {
.row {
margin-left: -30px;
*zoom: 1;
}
.row:before, .row:after {
display: table;
content: "";
}
.row:after {
clear: both;
}
[class*="span"] {
float: left;
margin-left: 30px;
}
.span1 {
width: 70px;
}
.span2 {
width: 170px;
}
.span3 {
width: 270px;
}
.span4 {
width: 370px;
}
.span5 {
width: 470px;
}
.span6 {
width: 570px;
}
.span7 {
width: 670px;
}
.span8 {
width: 770px;
}
.span9 {
width: 870px;
}
.span10 {
width: 970px;
}
.span11 {
width: 1070px;
}
.span12, .container {
width: 1170px;
}
.offset1 {
margin-left: 130px;
}
.offset2 {
margin-left: 230px;
}
.offset3 {
margin-left: 330px;
}
.offset4 {
margin-left: 430px;
}
.offset5 {
margin-left: 530px;
}
.offset6 {
margin-left: 630px;
}
.offset7 {
margin-left: 730px;
}
.offset8 {
margin-left: 830px;
}
.offset9 {
margin-left: 930px;
}
.offset10 {
margin-left: 1030px;
}
.offset11 {
margin-left: 1130px;
}
.row-fluid {
width: 100%;
*zoom: 1;
}
.row-fluid:before, .row-fluid:after {
display: table;
content: "";
}
.row-fluid:after {
clear: both;
}
.row-fluid > [class*="span"] {
float: left;
margin-left: 2.564102564%;
}
.row-fluid > [class*="span"]:first-child {
margin-left: 0;
}
.row-fluid .span1 {
width: 5.982905983%;
}
.row-fluid .span2 {
width: 14.529914530000001%;
}
.row-fluid .span3 {
width: 23.076923077%;
}
.row-fluid .span4 {
width: 31.623931624%;
}
.row-fluid .span5 {
width: 40.170940171000005%;
}
.row-fluid .span6 {
width: 48.717948718%;
}
.row-fluid .span7 {
width: 57.264957265%;
}
.row-fluid .span8 {
width: 65.81196581200001%;
}
.row-fluid .span9 {
width: 74.358974359%;
}
.row-fluid .span10 {
width: 82.905982906%;
}
.row-fluid .span11 {
width: 91.45299145300001%;
}
.row-fluid .span12 {
width: 100%;
}
input.span1, textarea.span1, .uneditable-input.span1 {
width: 60px;
}
input.span2, textarea.span2, .uneditable-input.span2 {
width: 160px;
}
input.span3, textarea.span3, .uneditable-input.span3 {
width: 260px;
}
input.span4, textarea.span4, .uneditable-input.span4 {
width: 360px;
}
input.span5, textarea.span5, .uneditable-input.span5 {
width: 460px;
}
input.span6, textarea.span6, .uneditable-input.span6 {
width: 560px;
}
input.span7, textarea.span7, .uneditable-input.span7 {
width: 660px;
}
input.span8, textarea.span8, .uneditable-input.span8 {
width: 760px;
}
input.span9, textarea.span9, .uneditable-input.span9 {
width: 860px;
}
input.span10, textarea.span10, .uneditable-input.span10 {
width: 960px;
}
input.span11, textarea.span11, .uneditable-input.span11 {
width: 1060px;
}
input.span12, textarea.span12, .uneditable-input.span12 {
width: 1160px;
}
.thumbnails {
margin-left: -30px;
}
.thumbnails > li {
margin-left: 30px;
}
}
-3
View File
@@ -1,3 +0,0 @@
.hidden{display:none;visibility:hidden;}
@media (max-width:480px){.nav-collapse{-webkit-transform:translate3d(0, 0, 0);} .page-header h1 small{display:block;line-height:18px;} input[class*="span"],select[class*="span"],textarea[class*="span"],.uneditable-input{display:block;width:100%;height:28px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-ms-box-sizing:border-box;box-sizing:border-box;} .input-prepend input[class*="span"],.input-append input[class*="span"]{width:auto;} input[type="checkbox"],input[type="radio"]{border:1px solid #ccc;} .form-horizontal .control-group>label{float:none;width:auto;padding-top:0;text-align:left;} .form-horizontal .controls{margin-left:0;} .form-horizontal .control-list{padding-top:0;} .form-horizontal .form-actions{padding-left:10px;padding-right:10px;} .modal{position:absolute;top:10px;left:10px;right:10px;width:auto;margin:0;}.modal.fade.in{top:auto;} .modal-header .close{padding:10px;margin:-10px;} .carousel-caption{position:static;}}@media (max-width:768px){.container{width:auto;padding:0 20px;} .row-fluid{width:100%;} .row{margin-left:0;} .row>[class*="span"],.row-fluid>[class*="span"]{float:none;display:block;width:auto;margin:0;}}@media (min-width:768px) and (max-width:980px){.row{margin-left:-20px;*zoom:1;}.row:before,.row:after{display:table;content:"";} .row:after{clear:both;} [class*="span"]{float:left;margin-left:20px;} .span1{width:42px;} .span2{width:104px;} .span3{width:166px;} .span4{width:228px;} .span5{width:290px;} .span6{width:352px;} .span7{width:414px;} .span8{width:476px;} .span9{width:538px;} .span10{width:600px;} .span11{width:662px;} .span12,.container{width:724px;} .offset1{margin-left:82px;} .offset2{margin-left:144px;} .offset3{margin-left:206px;} .offset4{margin-left:268px;} .offset5{margin-left:330px;} .offset6{margin-left:392px;} .offset7{margin-left:454px;} .offset8{margin-left:516px;} .offset9{margin-left:578px;} .offset10{margin-left:640px;} .offset11{margin-left:702px;} .row-fluid{width:100%;*zoom:1;}.row-fluid:before,.row-fluid:after{display:table;content:"";} .row-fluid:after{clear:both;} .row-fluid>[class*="span"]{float:left;margin-left:2.762430939%;} .row-fluid>[class*="span"]:first-child{margin-left:0;} .row-fluid .span1{width:5.801104972%;} .row-fluid .span2{width:14.364640883%;} .row-fluid .span3{width:22.928176794%;} .row-fluid .span4{width:31.491712705%;} .row-fluid .span5{width:40.055248616%;} .row-fluid .span6{width:48.618784527%;} .row-fluid .span7{width:57.182320438000005%;} .row-fluid .span8{width:65.74585634900001%;} .row-fluid .span9{width:74.30939226%;} .row-fluid .span10{width:82.87292817100001%;} .row-fluid .span11{width:91.436464082%;} .row-fluid .span12{width:99.999999993%;} input.span1,textarea.span1,.uneditable-input.span1{width:32px;} input.span2,textarea.span2,.uneditable-input.span2{width:94px;} input.span3,textarea.span3,.uneditable-input.span3{width:156px;} input.span4,textarea.span4,.uneditable-input.span4{width:218px;} input.span5,textarea.span5,.uneditable-input.span5{width:280px;} input.span6,textarea.span6,.uneditable-input.span6{width:342px;} input.span7,textarea.span7,.uneditable-input.span7{width:404px;} input.span8,textarea.span8,.uneditable-input.span8{width:466px;} input.span9,textarea.span9,.uneditable-input.span9{width:528px;} input.span10,textarea.span10,.uneditable-input.span10{width:590px;} input.span11,textarea.span11,.uneditable-input.span11{width:652px;} input.span12,textarea.span12,.uneditable-input.span12{width:714px;}}@media (max-width:980px){body{padding-top:0;} .navbar-fixed-top{position:static;margin-bottom:18px;} .navbar-fixed-top .navbar-inner{padding:5px;} .navbar .container{width:auto;padding:0;} .navbar .brand{padding-left:10px;padding-right:10px;margin:0 0 0 -5px;} .navbar .nav-collapse{clear:left;} .navbar .nav{float:none;margin:0 0 9px;} .navbar .nav>li{float:none;} .navbar .nav>li>a{margin-bottom:2px;} .navbar .nav>.divider-vertical{display:none;} .navbar .nav>li>a,.navbar .dropdown-menu a{padding:6px 15px;font-weight:bold;color:#999999;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;} .navbar .dropdown-menu li+li a{margin-bottom:2px;} .navbar .nav>li>a:hover,.navbar .dropdown-menu a:hover{background-color:#222222;} .navbar .dropdown-menu{position:static;top:auto;left:auto;float:none;display:block;max-width:none;margin:0 15px;padding:0;background-color:transparent;border:none;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;} .navbar .dropdown-menu:before,.navbar .dropdown-menu:after{display:none;} .navbar .dropdown-menu .divider{display:none;} .navbar-form,.navbar-search{float:none;padding:9px 15px;margin:9px 0;border-top:1px solid #222222;border-bottom:1px solid #222222;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.1);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.1);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 25Line truncated
-3365
View File
File diff suppressed because it is too large. Load diff
-610
View File
@@ -1,610 +0,0 @@
article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block;}
audio,canvas,video{display:inline-block;*display:inline;*zoom:1;}
audio:not([controls]){display:none;}
html{font-size:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px;}
a:hover,a:active{outline:0;}
sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline;}
sup{top:-0.5em;}
sub{bottom:-0.25em;}
img{max-width:100%;height:auto;border:0;-ms-interpolation-mode:bicubic;}
button,input,select,textarea{margin:0;font-size:100%;vertical-align:middle;}
button,input{*overflow:visible;line-height:normal;}
button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0;}
button,input[type="button"],input[type="reset"],input[type="submit"]{cursor:pointer;-webkit-appearance:button;}
input[type="search"]{-webkit-appearance:textfield;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;}
input[type="search"]::-webkit-search-decoration,input[type="search"]::-webkit-search-cancel-button{-webkit-appearance:none;}
textarea{overflow:auto;vertical-align:top;}
body{margin:0;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;line-height:18px;color:#333333;background-color:#ffffff;}
a{color:#0088cc;text-decoration:none;}
a:hover{color:#005580;text-decoration:underline;}
.row{margin-left:-20px;*zoom:1;}.row:before,.row:after{display:table;content:"";}
.row:after{clear:both;}
[class*="span"]{float:left;margin-left:20px;}
.span1{width:60px;}
.span2{width:140px;}
.span3{width:220px;}
.span4{width:300px;}
.span5{width:380px;}
.span6{width:460px;}
.span7{width:540px;}
.span8{width:620px;}
.span9{width:700px;}
.span10{width:780px;}
.span11{width:860px;}
.span12,.container{width:940px;}
.offset1{margin-left:100px;}
.offset2{margin-left:180px;}
.offset3{margin-left:260px;}
.offset4{margin-left:340px;}
.offset5{margin-left:420px;}
.offset6{margin-left:500px;}
.offset7{margin-left:580px;}
.offset8{margin-left:660px;}
.offset9{margin-left:740px;}
.offset10{margin-left:820px;}
.offset11{margin-left:900px;}
.row-fluid{width:100%;*zoom:1;}.row-fluid:before,.row-fluid:after{display:table;content:"";}
.row-fluid:after{clear:both;}
.row-fluid>[class*="span"]{float:left;margin-left:2.127659574%;}
.row-fluid>[class*="span"]:first-child{margin-left:0;}
.row-fluid .span1{width:6.382978723%;}
.row-fluid .span2{width:14.89361702%;}
.row-fluid .span3{width:23.404255317%;}
.row-fluid .span4{width:31.914893614%;}
.row-fluid .span5{width:40.425531911%;}
.row-fluid .span6{width:48.93617020799999%;}
.row-fluid .span7{width:57.446808505%;}
.row-fluid .span8{width:65.95744680199999%;}
.row-fluid .span9{width:74.468085099%;}
.row-fluid .span10{width:82.97872339599999%;}
.row-fluid .span11{width:91.489361693%;}
.row-fluid .span12{width:99.99999998999999%;}
.container{width:940px;margin-left:auto;margin-right:auto;*zoom:1;}.container:before,.container:after{display:table;content:"";}
.container:after{clear:both;}
.container-fluid{padding-left:20px;padding-right:20px;*zoom:1;}.container-fluid:before,.container-fluid:after{display:table;content:"";}
.container-fluid:after{clear:both;}
p{margin:0 0 9px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;line-height:18px;}p small{font-size:11px;color:#999999;}
.lead{margin-bottom:18px;font-size:20px;font-weight:200;line-height:27px;}
h1,h2,h3,h4,h5,h6{margin:0;font-weight:bold;color:#333333;text-rendering:optimizelegibility;}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small{font-weight:normal;color:#999999;}
h1{font-size:30px;line-height:36px;}h1 small{font-size:18px;}
h2{font-size:24px;line-height:36px;}h2 small{font-size:18px;}
h3{line-height:27px;font-size:18px;}h3 small{font-size:14px;}
h4,h5,h6{line-height:18px;}
h4{font-size:14px;}h4 small{font-size:12px;}
h5{font-size:12px;}
h6{font-size:11px;color:#999999;text-transform:uppercase;}
.page-header{padding-bottom:17px;margin:18px 0;border-bottom:1px solid #eeeeee;}
.page-header h1{line-height:1;}
ul,ol{padding:0;margin:0 0 9px 25px;}
ul ul,ul ol,ol ol,ol ul{margin-bottom:0;}
ul{list-style:disc;}
ol{list-style:decimal;}
li{line-height:18px;}
ul.unstyled{margin-left:0;list-style:none;}
dl{margin-bottom:18px;}
dt,dd{line-height:18px;}
dt{font-weight:bold;}
dd{margin-left:9px;}
hr{margin:18px 0;border:0;border-top:1px solid #e5e5e5;border-bottom:1px solid #ffffff;}
strong{font-weight:bold;}
em{font-style:italic;}
.muted{color:#999999;}
abbr{font-size:90%;text-transform:uppercase;border-bottom:1px dotted #ddd;cursor:help;}
blockquote{padding:0 0 0 15px;margin:0 0 18px;border-left:5px solid #eeeeee;}blockquote p{margin-bottom:0;font-size:16px;font-weight:300;line-height:22.5px;}
blockquote small{display:block;line-height:18px;color:#999999;}blockquote small:before{content:'\2014 \00A0';}
blockquote.pull-right{float:right;padding-left:0;padding-right:15px;border-left:0;border-right:5px solid #eeeeee;}blockquote.pull-right p,blockquote.pull-right small{text-align:right;}
q:before,q:after,blockquote:before,blockquote:after{content:"";}
address{display:block;margin-bottom:18px;line-height:18px;font-style:normal;}
small{font-size:100%;}
cite{font-style:normal;}
code,pre{padding:0 3px 2px;font-family:Menlo,Monaco,"Courier New",monospace;font-size:12px;color:#333333;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}
code{padding:3px 4px;color:#d14;background-color:#f7f7f9;border:1px solid #e1e1e8;}
pre{display:block;padding:8.5px;margin:0 0 9px;font-size:12px;line-height:18px;background-color:#f5f5f5;border:1px solid #ccc;border:1px solid rgba(0, 0, 0, 0.15);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;white-space:pre;white-space:pre-wrap;word-break:break-all;}pre.prettyprint{margin-bottom:18px;}
pre code{padding:0;background-color:transparent;}
form{margin:0 0 18px;}
fieldset{padding:0;margin:0;border:0;}
legend{display:block;width:100%;padding:0;margin-bottom:27px;font-size:19.5px;line-height:36px;color:#333333;border:0;border-bottom:1px solid #eee;}
label,input,button,select,textarea{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;font-weight:normal;line-height:18px;}
label{display:block;margin-bottom:5px;color:#333333;}
input,textarea,select,.uneditable-input{display:inline-block;width:210px;height:18px;padding:4px;margin-bottom:9px;font-size:13px;line-height:18px;color:#555555;border:1px solid #ccc;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}
.uneditable-textarea{width:auto;height:auto;}
label input,label textarea,label select{display:block;}
input[type="image"],input[type="checkbox"],input[type="radio"]{width:auto;height:auto;padding:0;margin:3px 0;*margin-top:0;line-height:normal;border:0;cursor:pointer;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
input[type="file"]{padding:initial;line-height:initial;border:initial;background-color:#ffffff;background-color:initial;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
input[type="button"],input[type="reset"],input[type="submit"]{width:auto;height:auto;}
select,input[type="file"]{height:28px;*margin-top:4px;line-height:28px;}
select{width:220px;background-color:#ffffff;}
select[multiple],select[size]{height:auto;}
input[type="image"]{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
textarea{height:auto;}
input[type="hidden"]{display:none;}
.radio,.checkbox{padding-left:18px;}
.radio input[type="radio"],.checkbox input[type="checkbox"]{float:left;margin-left:-18px;}
.controls>.radio:first-child,.controls>.checkbox:first-child{padding-top:5px;}
.radio.inline,.checkbox.inline{display:inline-block;margin-bottom:0;vertical-align:middle;}
.radio.inline+.radio.inline,.checkbox.inline+.checkbox.inline{margin-left:10px;}
.controls>.radio.inline:first-child,.controls>.checkbox.inline:first-child{padding-top:0;}
input,textarea{-webkit-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075);-moz-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075);box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075);-webkit-transition:border linear 0.2s,box-shadow linear 0.2s;-moz-transition:border linear 0.2s,box-shadow linear 0.2s;-ms-transition:border linear 0.2s,box-shadow linear 0.2s;-o-transition:border linear 0.2s,box-shadow linear 0.2s;transition:border linear 0.2s,box-shadow linear 0.2s;}
input:focus,textarea:focus{border-color:rgba(82, 168, 236, 0.8);-webkit-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075),0 0 8px rgba(82, 168, 236, 0.6);-moz-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075),0 0 8px rgba(82, 168, 236, 0.6);box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075),0 0 8px rgba(82, 168, 236, 0.6);outline:0;outline:thin dotted \9;}
input[type="file"]:focus,input[type="checkbox"]:focus,select:focus{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px;}
.input-mini{width:60px;}
.input-small{width:90px;}
.input-medium{width:150px;}
.input-large{width:210px;}
.input-xlarge{width:270px;}
.input-xxlarge{width:530px;}
input[class*="span"],select[class*="span"],textarea[class*="span"],.uneditable-input{float:none;margin-left:0;}
input.span1,textarea.span1,.uneditable-input.span1{width:50px;}
input.span2,textarea.span2,.uneditable-input.span2{width:130px;}
input.span3,textarea.span3,.uneditable-input.span3{width:210px;}
input.span4,textarea.span4,.uneditable-input.span4{width:290px;}
input.span5,textarea.span5,.uneditable-input.span5{width:370px;}
input.span6,textarea.span6,.uneditable-input.span6{width:450px;}
input.span7,textarea.span7,.uneditable-input.span7{width:530px;}
input.span8,textarea.span8,.uneditable-input.span8{width:610px;}
input.span9,textarea.span9,.uneditable-input.span9{width:690px;}
input.span10,textarea.span10,.uneditable-input.span10{width:770px;}
input.span11,textarea.span11,.uneditable-input.span11{width:850px;}
input.span12,textarea.span12,.uneditable-input.span12{width:930px;}
input[disabled],select[disabled],textarea[disabled],input[readonly],select[readonly],textarea[readonly]{background-color:#f5f5f5;border-color:#ddd;cursor:not-allowed;}
.control-group.warning>label,.control-group.warning .help-block,.control-group.warning .help-inline{color:#c09853;}
.control-group.warning input,.control-group.warning select,.control-group.warning textarea{color:#c09853;border-color:#c09853;}.control-group.warning input:focus,.control-group.warning select:focus,.control-group.warning textarea:focus{border-color:#a47e3c;-webkit-box-shadow:0 0 6px #dbc59e;-moz-box-shadow:0 0 6px #dbc59e;box-shadow:0 0 6px #dbc59e;}
.control-group.warning .input-prepend .add-on,.control-group.warning .input-append .add-on{color:#c09853;background-color:#fcf8e3;border-color:#c09853;}
.control-group.error>label,.control-group.error .help-block,.control-group.error .help-inline{color:#b94a48;}
.control-group.error input,.control-group.error select,.control-group.error textarea{color:#b94a48;border-color:#b94a48;}.control-group.error input:focus,.control-group.error select:focus,.control-group.error textarea:focus{border-color:#953b39;-webkit-box-shadow:0 0 6px #d59392;-moz-box-shadow:0 0 6px #d59392;box-shadow:0 0 6px #d59392;}
.control-group.error .input-prepend .add-on,.control-group.error .input-append .add-on{color:#b94a48;background-color:#f2dede;border-color:#b94a48;}
.control-group.success>label,.control-group.success .help-block,.control-group.success .help-inline{color:#468847;}
.control-group.success input,.control-group.success select,.control-group.success textarea{color:#468847;border-color:#468847;}.control-group.success input:focus,.control-group.success select:focus,.control-group.success textarea:focus{border-color:#356635;-webkit-box-shadow:0 0 6px #7aba7b;-moz-box-shadow:0 0 6px #7aba7b;box-shadow:0 0 6px #7aba7b;}
.control-group.success .input-prepend .add-on,.control-group.success .input-append .add-on{color:#468847;background-color:#dff0d8;border-color:#468847;}
input:focus:required:invalid,textarea:focus:required:invalid,select:focus:required:invalid{color:#b94a48;border-color:#ee5f5b;}input:focus:required:invalid:focus,textarea:focus:required:invalid:focus,select:focus:required:invalid:focus{border-color:#e9322d;-webkit-box-shadow:0 0 6px #f8b9b7;-moz-box-shadow:0 0 6px #f8b9b7;box-shadow:0 0 6px #f8b9b7;}
.form-actions{padding:17px 20px 18px;margin-top:18px;margin-bottom:18px;background-color:#f5f5f5;border-top:1px solid #ddd;}
.uneditable-input{display:block;background-color:#ffffff;border-color:#eee;-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);cursor:not-allowed;}
:-moz-placeholder{color:#999999;}
::-webkit-input-placeholder{color:#999999;}
.help-block{margin-top:5px;margin-bottom:0;color:#999999;}
.help-inline{display:inline-block;*display:inline;*zoom:1;margin-bottom:9px;vertical-align:middle;padding-left:5px;}
.input-prepend,.input-append{margin-bottom:5px;*zoom:1;}.input-prepend:before,.input-append:before,.input-prepend:after,.input-append:after{display:table;content:"";}
.input-prepend:after,.input-append:after{clear:both;}
.input-prepend input,.input-append input,.input-prepend .uneditable-input,.input-append .uneditable-input{-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;}.input-prepend input:focus,.input-append input:focus,.input-prepend .uneditable-input:focus,.input-append .uneditable-input:focus{position:relative;z-index:2;}
.input-prepend .uneditable-input,.input-append .uneditable-input{border-left-color:#ccc;}
.input-prepend .add-on,.input-append .add-on{float:left;display:block;width:auto;min-width:16px;height:18px;margin-right:-1px;padding:4px 5px;font-weight:normal;line-height:18px;color:#999999;text-align:center;text-shadow:0 1px 0 #ffffff;background-color:#f5f5f5;border:1px solid #ccc;-webkit-border-radius:3px 0 0 3px;-moz-border-radius:3px 0 0 3px;border-radius:3px 0 0 3px;}
.input-prepend .active,.input-append .active{background-color:#a9dba9;border-color:#46a546;}
.input-prepend .add-on{*margin-top:1px;}
.input-append input,.input-append .uneditable-input{float:left;-webkit-border-radius:3px 0 0 3px;-moz-border-radius:3px 0 0 3px;border-radius:3px 0 0 3px;}
.input-append .uneditable-input{border-right-color:#ccc;}
.input-append .add-on{margin-right:0;margin-left:-1px;-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;}
.input-append input:first-child{*margin-left:-160px;}.input-append input:first-child+.add-on{*margin-left:-21px;}
.search-query{padding-left:14px;padding-right:14px;margin-bottom:0;-webkit-border-radius:14px;-moz-border-radius:14px;border-radius:14px;}
.form-search input,.form-inline input,.form-horizontal input,.form-search textarea,.form-inline textarea,.form-horizontal textarea,.form-search select,.form-inline select,.form-horizontal select,.form-search .help-inline,.form-inline .help-inline,.form-horizontal .help-inline,.form-search .uneditable-input,.form-inline .uneditable-input,.form-horizontal .uneditable-input{display:inline-block;margin-bottom:0;}
.form-search label,.form-inline label,.form-search .input-append,.form-inline .input-append,.form-search .input-prepend,.form-inline .input-prepend{display:inline-block;}
.form-search .input-append .add-on,.form-inline .input-prepend .add-on,.form-search .input-append .add-on,.form-inline .input-prepend .add-on{vertical-align:middle;}
.control-group{margin-bottom:9px;}
.form-horizontal legend+.control-group{margin-top:18px;-webkit-margin-top-collapse:separate;}
.form-horizontal .control-group{margin-bottom:18px;*zoom:1;}.form-horizontal .control-group:before,.form-horizontal .control-group:after{display:table;content:"";}
.form-horizontal .control-group:after{clear:both;}
.form-horizontal .control-group>label{float:left;width:140px;padding-top:5px;text-align:right;}
.form-horizontal .controls{margin-left:160px;}
.form-horizontal .form-actions{padding-left:160px;}
table{max-width:100%;border-collapse:collapse;border-spacing:0;}
.table{width:100%;margin-bottom:18px;}.table th,.table td{padding:8px;line-height:18px;text-align:left;border-top:1px solid #ddd;}
.table th{font-weight:bold;vertical-align:bottom;}
.table td{vertical-align:top;}
.table thead:first-child tr th,.table thead:first-child tr td{border-top:0;}
.table tbody+tbody{border-top:2px solid #ddd;}
.table-condensed th,.table-condensed td{padding:4px 5px;}
.table-bordered{border:1px solid #ddd;border-collapse:separate;*border-collapse:collapsed;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}.table-bordered th+th,.table-bordered td+td,.table-bordered th+td,.table-bordered td+th{border-left:1px solid #ddd;}
.table-bordered thead:first-child tr:first-child th,.table-bordered tbody:first-child tr:first-child th,.table-bordered tbody:first-child tr:first-child td{border-top:0;}
.table-bordered thead:first-child tr:first-child th:first-child,.table-bordered tbody:first-child tr:first-child td:first-child{-webkit-border-radius:4px 0 0 0;-moz-border-radius:4px 0 0 0;border-radius:4px 0 0 0;}
.table-bordered thead:first-child tr:first-child th:last-child,.table-bordered tbody:first-child tr:first-child td:last-child{-webkit-border-radius:0 4px 0 0;-moz-border-radius:0 4px 0 0;border-radius:0 4px 0 0;}
.table-bordered thead:last-child tr:last-child th:first-child,.table-bordered tbody:last-child tr:last-child td:first-child{-webkit-border-radius:0 0 0 4px;-moz-border-radius:0 0 0 4px;border-radius:0 0 0 4px;}
.table-bordered thead:last-child tr:last-child th:last-child,.table-bordered tbody:last-child tr:last-child td:last-child{-webkit-border-radius:0 0 4px 0;-moz-border-radius:0 0 4px 0;border-radius:0 0 4px 0;}
.table-striped tbody tr:nth-child(odd) td,.table-striped tbody tr:nth-child(odd) th{background-color:#f9f9f9;}
table .span1{float:none;width:44px;margin-left:0;}
table .span2{float:none;width:124px;margin-left:0;}
table .span3{float:none;width:204px;margin-left:0;}
table .span4{float:none;width:284px;margin-left:0;}
table .span5{float:none;width:364px;margin-left:0;}
table .span6{float:none;width:444px;margin-left:0;}
table .span7{float:none;width:524px;margin-left:0;}
table .span8{float:none;width:604px;margin-left:0;}
table .span9{float:none;width:684px;margin-left:0;}
table .span10{float:none;width:764px;margin-left:0;}
table .span11{float:none;width:844px;margin-left:0;}
table .span12{float:none;width:924px;margin-left:0;}
[class^="icon-"]{display:inline-block;width:14px;height:14px;vertical-align:text-top;background-image:url(../img/glyphicons-halflings.png);background-position:14px 14px;background-repeat:no-repeat;*margin-right:.3em;}[class^="icon-"]:last-child{*margin-left:0;}
.icon-white{background-image:url(../img/glyphicons-halflings-white.png);}
.icon-glass{background-position:0 0;}
.icon-music{background-position:-24px 0;}
.icon-search{background-position:-48px 0;}
.icon-envelope{background-position:-72px 0;}
.icon-heart{background-position:-96px 0;}
.icon-star{background-position:-120px 0;}
.icon-star-empty{background-position:-144px 0;}
.icon-user{background-position:-168px 0;}
.icon-film{background-position:-192px 0;}
.icon-th-large{background-position:-216px 0;}
.icon-th{background-position:-240px 0;}
.icon-th-list{background-position:-264px 0;}
.icon-ok{background-position:-288px 0;}
.icon-remove{background-position:-312px 0;}
.icon-zoom-in{background-position:-336px 0;}
.icon-zoom-out{background-position:-360px 0;}
.icon-off{background-position:-384px 0;}
.icon-signal{background-position:-408px 0;}
.icon-cog{background-position:-432px 0;}
.icon-trash{background-position:-456px 0;}
.icon-home{background-position:0 -24px;}
.icon-file{background-position:-24px -24px;}
.icon-time{background-position:-48px -24px;}
.icon-road{background-position:-72px -24px;}
.icon-download-alt{background-position:-96px -24px;}
.icon-download{background-position:-120px -24px;}
.icon-upload{background-position:-144px -24px;}
.icon-inbox{background-position:-168px -24px;}
.icon-play-circle{background-position:-192px -24px;}
.icon-repeat{background-position:-216px -24px;}
.icon-refresh{background-position:-240px -24px;}
.icon-list-alt{background-position:-264px -24px;}
.icon-lock{background-position:-287px -24px;}
.icon-flag{background-position:-312px -24px;}
.icon-headphones{background-position:-336px -24px;}
.icon-volume-off{background-position:-360px -24px;}
.icon-volume-down{background-position:-384px -24px;}
.icon-volume-up{background-position:-408px -24px;}
.icon-qrcode{background-position:-432px -24px;}
.icon-barcode{background-position:-456px -24px;}
.icon-tag{background-position:0 -48px;}
.icon-tags{background-position:-25px -48px;}
.icon-book{background-position:-48px -48px;}
.icon-bookmark{background-position:-72px -48px;}
.icon-print{background-position:-96px -48px;}
.icon-camera{background-position:-120px -48px;}
.icon-font{background-position:-144px -48px;}
.icon-bold{background-position:-167px -48px;}
.icon-italic{background-position:-192px -48px;}
.icon-text-height{background-position:-216px -48px;}
.icon-text-width{background-position:-240px -48px;}
.icon-align-left{background-position:-264px -48px;}
.icon-align-center{background-position:-288px -48px;}
.icon-align-right{background-position:-312px -48px;}
.icon-align-justify{background-position:-336px -48px;}
.icon-list{background-position:-360px -48px;}
.icon-indent-left{background-position:-384px -48px;}
.icon-indent-right{background-position:-408px -48px;}
.icon-facetime-video{background-position:-432px -48px;}
.icon-picture{background-position:-456px -48px;}
.icon-pencil{background-position:0 -72px;}
.icon-map-marker{background-position:-24px -72px;}
.icon-adjust{background-position:-48px -72px;}
.icon-tint{background-position:-72px -72px;}
.icon-edit{background-position:-96px -72px;}
.icon-share{background-position:-120px -72px;}
.icon-check{background-position:-144px -72px;}
.icon-move{background-position:-168px -72px;}
.icon-step-backward{background-position:-192px -72px;}
.icon-fast-backward{background-position:-216px -72px;}
.icon-backward{background-position:-240px -72px;}
.icon-play{background-position:-264px -72px;}
.icon-pause{background-position:-288px -72px;}
.icon-stop{background-position:-312px -72px;}
.icon-forward{background-position:-336px -72px;}
.icon-fast-forward{background-position:-360px -72px;}
.icon-step-forward{background-position:-384px -72px;}
.icon-eject{background-position:-408px -72px;}
.icon-chevron-left{background-position:-432px -72px;}
.icon-chevron-right{background-position:-456px -72px;}
.icon-plus-sign{background-position:0 -96px;}
.icon-minus-sign{background-position:-24px -96px;}
.icon-remove-sign{background-position:-48px -96px;}
.icon-ok-sign{background-position:-72px -96px;}
.icon-question-sign{background-position:-96px -96px;}
.icon-info-sign{background-position:-120px -96px;}
.icon-screenshot{background-position:-144px -96px;}
.icon-remove-circle{background-position:-168px -96px;}
.icon-ok-circle{background-position:-192px -96px;}
.icon-ban-circle{background-position:-216px -96px;}
.icon-arrow-left{background-position:-240px -96px;}
.icon-arrow-right{background-position:-264px -96px;}
.icon-arrow-up{background-position:-289px -96px;}
.icon-arrow-down{background-position:-312px -96px;}
.icon-share-alt{background-position:-336px -96px;}
.icon-resize-full{background-position:-360px -96px;}
.icon-resize-small{background-position:-384px -96px;}
.icon-plus{background-position:-408px -96px;}
.icon-minus{background-position:-433px -96px;}
.icon-asterisk{background-position:-456px -96px;}
.icon-exclamation-sign{background-position:0 -120px;}
.icon-gift{background-position:-24px -120px;}
.icon-leaf{background-position:-48px -120px;}
.icon-fire{background-position:-72px -120px;}
.icon-eye-open{background-position:-96px -120px;}
.icon-eye-close{background-position:-120px -120px;}
.icon-warning-sign{background-position:-144px -120px;}
.icon-plane{background-position:-168px -120px;}
.icon-calendar{background-position:-192px -120px;}
.icon-random{background-position:-216px -120px;}
.icon-comment{background-position:-240px -120px;}
.icon-magnet{background-position:-264px -120px;}
.icon-chevron-up{background-position:-288px -120px;}
.icon-chevron-down{background-position:-313px -119px;}
.icon-retweet{background-position:-336px -120px;}
.icon-shopping-cart{background-position:-360px -120px;}
.icon-folder-close{background-position:-384px -120px;}
.icon-folder-open{background-position:-408px -120px;}
.icon-resize-vertical{background-position:-432px -119px;}
.icon-resize-horizontal{background-position:-456px -118px;}
.dropdown{position:relative;}
.dropdown-toggle{*margin-bottom:-3px;}
.dropdown-toggle:active,.open .dropdown-toggle{outline:0;}
.caret{display:inline-block;width:0;height:0;text-indent:-99999px;*text-indent:0;vertical-align:top;border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid #000000;opacity:0.3;filter:alpha(opacity=30);content:"\2193";}
.dropdown .caret{margin-top:8px;margin-left:2px;}
.dropdown:hover .caret,.open.dropdown .caret{opacity:1;filter:alpha(opacity=100);}
.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;float:left;display:none;min-width:160px;max-width:220px;_width:160px;padding:4px 0;margin:0;list-style:none;background-color:#ffffff;border-color:#ccc;border-color:rgba(0, 0, 0, 0.2);border-style:solid;border-width:1px;-webkit-border-radius:0 0 5px 5px;-moz-border-radius:0 0 5px 5px;border-radius:0 0 5px 5px;-webkit-box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);-moz-box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);-webkit-background-clip:padding-box;-moz-background-clip:padding;background-clip:padding-box;*border-right-width:2px;*border-bottom-width:2px;}.dropdown-menu.bottom-up{top:auto;bottom:100%;margin-bottom:2px;}
.dropdown-menu .divider{height:1px;margin:5px 1px;overflow:hidden;background-color:#e5e5e5;border-bottom:1px solid #ffffff;*width:100%;*margin:-5px 0 5px;}
.dropdown-menu a{display:block;padding:3px 15px;clear:both;font-weight:normal;line-height:18px;color:#555555;white-space:nowrap;}
.dropdown-menu li>a:hover,.dropdown-menu .active>a,.dropdown-menu .active>a:hover{color:#ffffff;text-decoration:none;background-color:#0088cc;}
.dropdown.open{*z-index:1000;}.dropdown.open .dropdown-toggle{color:#ffffff;background:#ccc;background:rgba(0, 0, 0, 0.3);}
.dropdown.open .dropdown-menu{display:block;}
.typeahead{margin-top:2px;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #eee;border:1px solid rgba(0, 0, 0, 0.05);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);}.well blockquote{border-color:#ddd;border-color:rgba(0, 0, 0, 0.15);}
.fade{-webkit-transition:opacity 0.15s linear;-moz-transition:opacity 0.15s linear;-ms-transition:opacity 0.15s linear;-o-transition:opacity 0.15s linear;transition:opacity 0.15s linear;opacity:0;}.fade.in{opacity:1;}
.collapse{-webkit-transition:height 0.35s ease;-moz-transition:height 0.35s ease;-ms-transition:height 0.35s ease;-o-transition:height 0.35s ease;transition:height 0.35s ease;position:relative;overflow:hidden;height:0;}.collapse.in{height:auto;}
.close{float:right;font-size:20px;font-weight:bold;line-height:18px;color:#000000;text-shadow:0 1px 0 #ffffff;opacity:0.2;filter:alpha(opacity=20);}.close:hover{color:#000000;text-decoration:none;opacity:0.4;filter:alpha(opacity=40);cursor:pointer;}
.btn{display:inline-block;padding:4px 10px 4px;font-size:13px;line-height:18px;color:#333333;text-align:center;text-shadow:0 1px 1px rgba(255, 255, 255, 0.75);background-color:#fafafa;background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));background-image:-webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);background-image:-ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-repeat:no-repeat;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0);border:1px solid #ccc;border-bottom-color:#bbb;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);cursor:pointer;*margin-left:.3em;}.btn:first-child{*margin-left:0;}
.btn:hover{color:#333333;text-decoration:none;background-color:#e6e6e6;background-position:0 -15px;-webkit-transition:background-position 0.1s linear;-moz-transition:background-position 0.1s linear;-ms-transition:background-position 0.1s linear;-o-transition:background-position 0.1s linear;transition:background-position 0.1s linear;}
.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px;}
.btn.active,.btn:active{background-image:none;-webkit-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);background-color:#e6e6e6;background-color:#d9d9d9 \9;color:rgba(0, 0, 0, 0.5);outline:0;}
.btn.disabled,.btn[disabled]{cursor:default;background-image:none;background-color:#e6e6e6;opacity:0.65;filter:alpha(opacity=65);-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
.btn-large{padding:9px 14px;font-size:15px;line-height:normal;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;}
.btn-large .icon{margin-top:1px;}
.btn-small{padding:5px 9px;font-size:11px;line-height:16px;}
.btn-small .icon{margin-top:-1px;}
.btn-primary,.btn-primary:hover,.btn-warning,.btn-warning:hover,.btn-danger,.btn-danger:hover,.btn-success,.btn-success:hover,.btn-info,.btn-info:hover{text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);color:#ffffff;}
.btn-primary.active,.btn-warning.active,.btn-danger.active,.btn-success.active,.btn-info.active{color:rgba(255, 255, 255, 0.75);}
.btn-primary{background-color:#006dcc;background-image:-moz-linear-gradient(top, #0088cc, #0044cc);background-image:-ms-linear-gradient(top, #0088cc, #0044cc);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc));background-image:-webkit-linear-gradient(top, #0088cc, #0044cc);background-image:-o-linear-gradient(top, #0088cc, #0044cc);background-image:linear-gradient(top, #0088cc, #0044cc);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#0088cc', endColorstr='#0044cc', GradientType=0);border-color:#0044cc #0044cc #002a80;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-primary:hover,.btn-primary:active,.btn-primary.active,.btn-primary.disabled,.btn-primary[disabled]{background-color:#0044cc;}
.btn-primary:active,.btn-primary.active{background-color:#003399 \9;}
.btn-warning{background-color:#faa732;background-image:-moz-linear-gradient(top, #fbb450, #f89406);background-image:-ms-linear-gradient(top, #fbb450, #f89406);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#fbb450), to(#f89406));background-image:-webkit-linear-gradient(top, #fbb450, #f89406);background-image:-o-linear-gradient(top, #fbb450, #f89406);background-image:linear-gradient(top, #fbb450, #f89406);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fbb450', endColorstr='#f89406', GradientType=0);border-color:#f89406 #f89406 #ad6704;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-warning:hover,.btn-warning:active,.btn-warning.active,.btn-warning.disabled,.btn-warning[disabled]{background-color:#f89406;}
.btn-warning:active,.btn-warning.active{background-color:#c67605 \9;}
.btn-danger{background-color:#da4f49;background-image:-moz-linear-gradient(top, #ee5f5b, #bd362f);background-image:-ms-linear-gradient(top, #ee5f5b, #bd362f);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ee5f5b), to(#bd362f));background-image:-webkit-linear-gradient(top, #ee5f5b, #bd362f);background-image:-o-linear-gradient(top, #ee5f5b, #bd362f);background-image:linear-gradient(top, #ee5f5b, #bd362f);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#bd362f', GradientType=0);border-color:#bd362f #bd362f #802420;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-danger:hover,.btn-danger:active,.btn-danger.active,.btn-danger.disabled,.btn-danger[disabled]{background-color:#bd362f;}
.btn-danger:active,.btn-danger.active{background-color:#942a25 \9;}
.btn-success{background-color:#5bb75b;background-image:-moz-linear-gradient(top, #62c462, #51a351);background-image:-ms-linear-gradient(top, #62c462, #51a351);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#62c462), to(#51a351));background-image:-webkit-linear-gradient(top, #62c462, #51a351);background-image:-o-linear-gradient(top, #62c462, #51a351);background-image:linear-gradient(top, #62c462, #51a351);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#62c462', endColorstr='#51a351', GradientType=0);border-color:#51a351 #51a351 #387038;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-success:hover,.btn-success:active,.btn-success.active,.btn-success.disabled,.btn-success[disabled]{background-color:#51a351;}
.btn-success:active,.btn-success.active{background-color:#408140 \9;}
.btn-info{background-color:#49afcd;background-image:-moz-linear-gradient(top, #5bc0de, #2f96b4);background-image:-ms-linear-gradient(top, #5bc0de, #2f96b4);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#5bc0de), to(#2f96b4));background-image:-webkit-linear-gradient(top, #5bc0de, #2f96b4);background-image:-o-linear-gradient(top, #5bc0de, #2f96b4);background-image:linear-gradient(top, #5bc0de, #2f96b4);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#5bc0de', endColorstr='#2f96b4', GradientType=0);border-color:#2f96b4 #2f96b4 #1f6377;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-info:hover,.btn-info:active,.btn-info.active,.btn-info.disabled,.btn-info[disabled]{background-color:#2f96b4;}
.btn-info:active,.btn-info.active{background-color:#24748c \9;}
button.btn,input[type="submit"].btn{*padding-top:2px;*padding-bottom:2px;}button.btn::-moz-focus-inner,input[type="submit"].btn::-moz-focus-inner{padding:0;border:0;}
button.btn.large,input[type="submit"].btn.large{*padding-top:7px;*padding-bottom:7px;}
button.btn.small,input[type="submit"].btn.small{*padding-top:3px;*padding-bottom:3px;}
.btn-group{position:relative;*zoom:1;*margin-left:.3em;}.btn-group:before,.btn-group:after{display:table;content:"";}
.btn-group:after{clear:both;}
.btn-group:first-child{*margin-left:0;}
.btn-group+.btn-group{margin-left:5px;}
.btn-toolbar{margin-top:9px;margin-bottom:9px;}.btn-toolbar .btn-group{display:inline-block;*display:inline;*zoom:1;}
.btn-group .btn{position:relative;float:left;margin-left:-1px;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
.btn-group .btn:first-child{margin-left:0;-webkit-border-top-left-radius:4px;-moz-border-radius-topleft:4px;border-top-left-radius:4px;-webkit-border-bottom-left-radius:4px;-moz-border-radius-bottomleft:4px;border-bottom-left-radius:4px;}
.btn-group .btn:last-child,.btn-group .dropdown-toggle{-webkit-border-top-right-radius:4px;-moz-border-radius-topright:4px;border-top-right-radius:4px;-webkit-border-bottom-right-radius:4px;-moz-border-radius-bottomright:4px;border-bottom-right-radius:4px;}
.btn-group .btn.large:first-child{margin-left:0;-webkit-border-top-left-radius:6px;-moz-border-radius-topleft:6px;border-top-left-radius:6px;-webkit-border-bottom-left-radius:6px;-moz-border-radius-bottomleft:6px;border-bottom-left-radius:6px;}
.btn-group .btn.large:last-child,.btn-group .large.dropdown-toggle{-webkit-border-top-right-radius:6px;-moz-border-radius-topright:6px;border-top-right-radius:6px;-webkit-border-bottom-right-radius:6px;-moz-border-radius-bottomright:6px;border-bottom-right-radius:6px;}
.btn-group .btn:hover,.btn-group .btn:focus,.btn-group .btn:active,.btn-group .btn.active{z-index:2;}
.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0;}
.btn-group .dropdown-toggle{padding-left:8px;padding-right:8px;-webkit-box-shadow:inset 1px 0 0 rgba(255, 255, 255, 0.125),inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 1px 0 0 rgba(255, 255, 255, 0.125),inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 1px 0 0 rgba(255, 255, 255, 0.125),inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);*padding-top:5px;*padding-bottom:5px;}
.btn-group.open{*z-index:1000;}.btn-group.open .dropdown-menu{display:block;margin-top:1px;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;}
.btn-group.open .dropdown-toggle{background-image:none;-webkit-box-shadow:inset 0 1px 6px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 6px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 6px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);}
.btn .caret{margin-top:7px;margin-left:0;}
.btn:hover .caret,.open.btn-group .caret{opacity:1;filter:alpha(opacity=100);}
.btn-primary .caret,.btn-danger .caret,.btn-info .caret,.btn-success .caret{border-top-color:#ffffff;opacity:0.75;filter:alpha(opacity=75);}
.btn-small .caret{margin-top:4px;}
.alert{padding:8px 35px 8px 14px;margin-bottom:18px;text-shadow:0 1px 0 rgba(255, 255, 255, 0.5);background-color:#fcf8e3;border:1px solid #fbeed5;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.alert,.alert-heading{color:#c09853;}
.alert .close{position:relative;top:-2px;right:-21px;line-height:18px;}
.alert-success{background-color:#dff0d8;border-color:#d6e9c6;}
.alert-success,.alert-success .alert-heading{color:#468847;}
.alert-danger,.alert-error{background-color:#f2dede;border-color:#eed3d7;}
.alert-danger,.alert-error,.alert-danger .alert-heading,.alert-error .alert-heading{color:#b94a48;}
.alert-info{background-color:#d9edf7;border-color:#bce8f1;}
.alert-info,.alert-info .alert-heading{color:#3a87ad;}
.alert-block{padding-top:14px;padding-bottom:14px;}
.alert-block>p,.alert-block>ul{margin-bottom:0;}
.alert-block p+p{margin-top:5px;}
.nav{margin-left:0;margin-bottom:18px;list-style:none;}
.nav>li>a{display:block;}
.nav>li>a:hover{text-decoration:none;background-color:#eeeeee;}
.nav-list{padding-left:14px;padding-right:14px;margin-bottom:0;}
.nav-list>li>a,.nav-list .nav-header{display:block;padding:3px 15px;margin-left:-15px;margin-right:-15px;text-shadow:0 1px 0 rgba(255, 255, 255, 0.5);}
.nav-list .nav-header{font-size:11px;font-weight:bold;line-height:18px;color:#999999;text-transform:uppercase;}
.nav-list>li+.nav-header{margin-top:9px;}
.nav-list .active>a,.nav-list .active>a:hover{color:#ffffff;text-shadow:0 -1px 0 rgba(0, 0, 0, 0.2);background-color:#0088cc;}
.nav-list [class^="icon-"]{margin-right:2px;}
.nav-tabs,.nav-pills{*zoom:1;}.nav-tabs:before,.nav-pills:before,.nav-tabs:after,.nav-pills:after{display:table;content:"";}
.nav-tabs:after,.nav-pills:after{clear:both;}
.nav-tabs>li,.nav-pills>li{float:left;}
.nav-tabs>li>a,.nav-pills>li>a{padding-right:12px;padding-left:12px;margin-right:2px;line-height:14px;}
.nav-tabs{border-bottom:1px solid #ddd;}
.nav-tabs>li{margin-bottom:-1px;}
.nav-tabs>li>a{padding-top:9px;padding-bottom:9px;border:1px solid transparent;-webkit-border-radius:4px 4px 0 0;-moz-border-radius:4px 4px 0 0;border-radius:4px 4px 0 0;}.nav-tabs>li>a:hover{border-color:#eeeeee #eeeeee #dddddd;}
.nav-tabs>.active>a,.nav-tabs>.active>a:hover{color:#555555;background-color:#ffffff;border:1px solid #ddd;border-bottom-color:transparent;cursor:default;}
.nav-pills>li>a{padding-top:8px;padding-bottom:8px;margin-top:2px;margin-bottom:2px;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;}
.nav-pills .active>a,.nav-pills .active>a:hover{color:#ffffff;background-color:#0088cc;}
.nav-stacked>li{float:none;}
.nav-stacked>li>a{margin-right:0;}
.nav-tabs.nav-stacked{border-bottom:0;}
.nav-tabs.nav-stacked>li>a{border:1px solid #ddd;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
.nav-tabs.nav-stacked>li:first-child>a{-webkit-border-radius:4px 4px 0 0;-moz-border-radius:4px 4px 0 0;border-radius:4px 4px 0 0;}
.nav-tabs.nav-stacked>li:last-child>a{-webkit-border-radius:0 0 4px 4px;-moz-border-radius:0 0 4px 4px;border-radius:0 0 4px 4px;}
.nav-tabs.nav-stacked>li>a:hover{border-color:#ddd;z-index:2;}
.nav-pills.nav-stacked>li>a{margin-bottom:3px;}
.nav-pills.nav-stacked>li:last-child>a{margin-bottom:1px;}
.nav-tabs .dropdown-menu,.nav-pills .dropdown-menu{margin-top:1px;border-width:1px;}
.nav-pills .dropdown-menu{-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.nav-tabs .dropdown-toggle .caret,.nav-pills .dropdown-toggle .caret{border-top-color:#0088cc;margin-top:6px;}
.nav-tabs .dropdown-toggle:hover .caret,.nav-pills .dropdown-toggle:hover .caret{border-top-color:#005580;}
.nav-tabs .active .dropdown-toggle .caret,.nav-pills .active .dropdown-toggle .caret{border-top-color:#333333;}
.nav>.dropdown.active>a:hover{color:#000000;cursor:pointer;}
.nav-tabs .open .dropdown-toggle,.nav-pills .open .dropdown-toggle,.nav>.open.active>a:hover{color:#ffffff;background-color:#999999;border-color:#999999;}
.nav .open .caret,.nav .open.active .caret,.nav .open a:hover .caret{border-top-color:#ffffff;opacity:1;filter:alpha(opacity=100);}
.tabs-stacked .open>a:hover{border-color:#999999;}
.tabbable{*zoom:1;}.tabbable:before,.tabbable:after{display:table;content:"";}
.tabbable:after{clear:both;}
.tabs-below .nav-tabs,.tabs-right .nav-tabs,.tabs-left .nav-tabs{border-bottom:0;}
.tab-content>.tab-pane,.pill-content>.pill-pane{display:none;}
.tab-content>.active,.pill-content>.active{display:block;}
.tabs-below .nav-tabs{border-top:1px solid #ddd;}
.tabs-below .nav-tabs>li{margin-top:-1px;margin-bottom:0;}
.tabs-below .nav-tabs>li>a{-webkit-border-radius:0 0 4px 4px;-moz-border-radius:0 0 4px 4px;border-radius:0 0 4px 4px;}.tabs-below .nav-tabs>li>a:hover{border-bottom-color:transparent;border-top-color:#ddd;}
.tabs-below .nav-tabs .active>a,.tabs-below .nav-tabs .active>a:hover{border-color:transparent #ddd #ddd #ddd;}
.tabs-left .nav-tabs>li,.tabs-right .nav-tabs>li{float:none;}
.tabs-left .nav-tabs>li>a,.tabs-right .nav-tabs>li>a{min-width:74px;margin-right:0;margin-bottom:3px;}
.tabs-left .nav-tabs{float:left;margin-right:19px;border-right:1px solid #ddd;}
.tabs-left .nav-tabs>li>a{margin-right:-1px;-webkit-border-radius:4px 0 0 4px;-moz-border-radius:4px 0 0 4px;border-radius:4px 0 0 4px;}
.tabs-left .nav-tabs>li>a:hover{border-color:#eeeeee #dddddd #eeeeee #eeeeee;}
.tabs-left .nav-tabs .active>a,.tabs-left .nav-tabs .active>a:hover{border-color:#ddd transparent #ddd #ddd;*border-right-color:#ffffff;}
.tabs-right .nav-tabs{float:right;margin-left:19px;border-left:1px solid #ddd;}
.tabs-right .nav-tabs>li>a{margin-left:-1px;-webkit-border-radius:0 4px 4px 0;-moz-border-radius:0 4px 4px 0;border-radius:0 4px 4px 0;}
.tabs-right .nav-tabs>li>a:hover{border-color:#eeeeee #eeeeee #eeeeee #dddddd;}
.tabs-right .nav-tabs .active>a,.tabs-right .nav-tabs .active>a:hover{border-color:#ddd #ddd #ddd transparent;*border-left-color:#ffffff;}
.navbar{overflow:visible;margin-bottom:18px;}
.navbar-inner{padding-left:20px;padding-right:20px;background-color:#2c2c2c;background-image:-moz-linear-gradient(top, #333333, #222222);background-image:-ms-linear-gradient(top, #333333, #222222);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#333333), to(#222222));background-image:-webkit-linear-gradient(top, #333333, #222222);background-image:-o-linear-gradient(top, #333333, #222222);background-image:linear-gradient(top, #333333, #222222);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#333333', endColorstr='#222222', GradientType=0);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);-moz-box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);}
.btn-navbar{display:none;float:right;padding:7px 10px;margin-left:5px;margin-right:5px;background-color:#2c2c2c;background-image:-moz-linear-gradient(top, #333333, #222222);background-image:-ms-linear-gradient(top, #333333, #222222);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#333333), to(#222222));background-image:-webkit-linear-gradient(top, #333333, #222222);background-image:-o-linear-gradient(top, #333333, #222222);background-image:linear-gradient(top, #333333, #222222);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#333333', endColorstr='#222222', GradientType=0);border-color:#222222 #222222 #000000;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.075);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.075);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.075);}.btn-navbar:hover,.btn-navbar:active,.btn-navbar.active,.btn-navbar.disabled,.btn-navbar[disabled]{background-color:#222222;}
.btn-navbar:active,.btn-navbar.active{background-color:#080808 \9;}
.btn-navbar .icon-bar{display:block;width:18px;height:2px;background-color:#f5f5f5;-webkit-border-radius:1px;-moz-border-radius:1px;border-radius:1px;-webkit-box-shadow:0 1px 0 rgba(0, 0, 0, 0.25);-moz-box-shadow:0 1px 0 rgba(0, 0, 0, 0.25);box-shadow:0 1px 0 rgba(0, 0, 0, 0.25);}
.btn-navbar .icon-bar+.icon-bar{margin-top:3px;}
.nav-collapse.collapse{height:auto;}
.navbar .brand:hover{text-decoration:none;}
.navbar .brand{float:left;display:block;padding:8px 20px 12px;margin-left:-20px;font-size:20px;font-weight:200;line-height:1;color:#ffffff;}
.navbar .navbar-text{margin-bottom:0;line-height:40px;color:#999999;}.navbar .navbar-text a:hover{color:#ffffff;background-color:transparent;}
.navbar .btn,.navbar .btn-group{margin-top:5px;}
.navbar .btn-group .btn{margin-top:0;}
.navbar-form{margin-bottom:0;*zoom:1;}.navbar-form:before,.navbar-form:after{display:table;content:"";}
.navbar-form:after{clear:both;}
.navbar-form input,.navbar-form select{display:inline-block;margin-top:5px;margin-bottom:0;}
.navbar-form .radio,.navbar-form .checkbox{margin-top:5px;}
.navbar-form input[type="image"],.navbar-form input[type="checkbox"],.navbar-form input[type="radio"]{margin-top:3px;}
.navbar-search{position:relative;float:left;margin-top:6px;margin-bottom:0;}.navbar-search .search-query{padding:4px 9px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;font-weight:normal;line-height:1;color:#ffffff;color:rgba(255, 255, 255, 0.75);background:#666;background:rgba(255, 255, 255, 0.3);border:1px solid #111;-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.15);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.15);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.15);-webkit-transition:none;-moz-transition:none;-ms-transition:none;-o-transition:none;transition:none;}.navbar-search .search-query :-moz-placeholder{color:#eeeeee;}
.navbar-search .search-query::-webkit-input-placeholder{color:#eeeeee;}
.navbar-search .search-query:hover{color:#ffffff;background-color:#999999;background-color:rgba(255, 255, 255, 0.5);}
.navbar-search .search-query:focus,.navbar-search .search-query.focused{padding:5px 10px;color:#333333;text-shadow:0 1px 0 #ffffff;background-color:#ffffff;border:0;-webkit-box-shadow:0 0 3px rgba(0, 0, 0, 0.15);-moz-box-shadow:0 0 3px rgba(0, 0, 0, 0.15);box-shadow:0 0 3px rgba(0, 0, 0, 0.15);outline:0;}
.navbar-fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030;}
.navbar-fixed-top .navbar-inner{padding-left:0;padding-right:0;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
.navbar .nav{position:relative;left:0;display:block;float:left;margin:0 10px 0 0;}
.navbar .nav.pull-right{float:right;}
.navbar .nav>li{display:block;float:left;}
.navbar .nav>li>a{float:none;padding:10px 10px 11px;line-height:19px;color:#999999;text-decoration:none;text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);}
.navbar .nav>li>a:hover{background-color:transparent;color:#ffffff;text-decoration:none;}
.navbar .nav .active>a,.navbar .nav .active>a:hover{color:#ffffff;text-decoration:none;background-color:#222222;background-color:rgba(0, 0, 0, 0.5);}
.navbar .divider-vertical{height:40px;width:1px;margin:0 9px;overflow:hidden;background-color:#222222;border-right:1px solid #333333;}
.navbar .nav.pull-right{margin-left:10px;margin-right:0;}
.navbar .dropdown-menu{margin-top:1px;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}.navbar .dropdown-menu:before{content:'';display:inline-block;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:7px solid #ccc;border-bottom-color:rgba(0, 0, 0, 0.2);position:absolute;top:-7px;left:9px;}
.navbar .dropdown-menu:after{content:'';display:inline-block;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid #ffffff;position:absolute;top:-6px;left:10px;}
.navbar .nav .dropdown-toggle .caret,.navbar .nav .open.dropdown .caret{border-top-color:#ffffff;}
.navbar .nav .active .caret{opacity:1;filter:alpha(opacity=100);}
.navbar .nav .open>.dropdown-toggle,.navbar .nav .active>.dropdown-toggle,.navbar .nav .open.active>.dropdown-toggle{background-color:transparent;}
.navbar .nav .active>.dropdown-toggle:hover{color:#ffffff;}
.navbar .nav.pull-right .dropdown-menu{left:auto;right:0;}.navbar .nav.pull-right .dropdown-menu:before{left:auto;right:12px;}
.navbar .nav.pull-right .dropdown-menu:after{left:auto;right:13px;}
.breadcrumb{padding:7px 14px;margin:0 0 18px;background-color:#fbfbfb;background-image:-moz-linear-gradient(top, #ffffff, #f5f5f5);background-image:-ms-linear-gradient(top, #ffffff, #f5f5f5);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#f5f5f5));background-image:-webkit-linear-gradient(top, #ffffff, #f5f5f5);background-image:-o-linear-gradient(top, #ffffff, #f5f5f5);background-image:linear-gradient(top, #ffffff, #f5f5f5);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#f5f5f5', GradientType=0);border:1px solid #ddd;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;-webkit-box-shadow:inset 0 1px 0 #ffffff;-moz-box-shadow:inset 0 1px 0 #ffffff;box-shadow:inset 0 1px 0 #ffffff;}.breadcrumb li{display:inline;text-shadow:0 1px 0 #ffffff;}
.breadcrumb .divider{padding:0 5px;color:#999999;}
.breadcrumb .active a{color:#333333;}
.pagination{height:36px;margin:18px 0;}
.pagination ul{display:inline-block;*display:inline;*zoom:1;margin-left:0;margin-bottom:0;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;-webkit-box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);}
.pagination li{display:inline;}
.pagination a{float:left;padding:0 14px;line-height:34px;text-decoration:none;border:1px solid #ddd;border-left-width:0;}
.pagination a:hover,.pagination .active a{background-color:#f5f5f5;}
.pagination .active a{color:#999999;cursor:default;}
.pagination .disabled a,.pagination .disabled a:hover{color:#999999;background-color:transparent;cursor:default;}
.pagination li:first-child a{border-left-width:1px;-webkit-border-radius:3px 0 0 3px;-moz-border-radius:3px 0 0 3px;border-radius:3px 0 0 3px;}
.pagination li:last-child a{-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;}
.pagination-centered{text-align:center;}
.pagination-right{text-align:right;}
.pager{margin-left:0;margin-bottom:18px;list-style:none;text-align:center;*zoom:1;}.pager:before,.pager:after{display:table;content:"";}
.pager:after{clear:both;}
.pager li{display:inline;}
.pager a{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;-webkit-border-radius:15px;-moz-border-radius:15px;border-radius:15px;}
.pager a:hover{text-decoration:none;background-color:#f5f5f5;}
.pager .next a{float:right;}
.pager .previous a{float:left;}
.modal-open .dropdown-menu{z-index:2050;}
.modal-open .dropdown.open{*z-index:2050;}
.modal-open .popover{z-index:2060;}
.modal-open .tooltip{z-index:2070;}
.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000000;}.modal-backdrop.fade{opacity:0;}
.modal-backdrop,.modal-backdrop.fade.in{opacity:0.8;filter:alpha(opacity=80);}
.modal{position:fixed;top:50%;left:50%;z-index:1050;max-height:500px;overflow:auto;width:560px;margin:-250px 0 0 -280px;background-color:#ffffff;border:1px solid #999;border:1px solid rgba(0, 0, 0, 0.3);*border:1px solid #999;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-moz-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;}.modal.fade{-webkit-transition:opacity .3s linear, top .3s ease-out;-moz-transition:opacity .3s linear, top .3s ease-out;-ms-transition:opacity .3s linear, top .3s ease-out;-o-transition:opacity .3s linear, top .3s ease-out;transition:opacity .3s linear, top .3s ease-out;top:-25%;}
.modal.fade.in{top:50%;}
.modal-header{padding:9px 15px;border-bottom:1px solid #eee;}.modal-header .close{margin-top:2px;}
.modal-body{padding:15px;}
.modal-footer{padding:14px 15px 15px;margin-bottom:0;background-color:#f5f5f5;border-top:1px solid #ddd;-webkit-border-radius:0 0 6px 6px;-moz-border-radius:0 0 6px 6px;border-radius:0 0 6px 6px;-webkit-box-shadow:inset 0 1px 0 #ffffff;-moz-box-shadow:inset 0 1px 0 #ffffff;box-shadow:inset 0 1px 0 #ffffff;*zoom:1;}.modal-footer:before,.modal-footer:after{display:table;content:"";}
.modal-footer:after{clear:both;}
.modal-footer .btn{float:right;margin-left:5px;margin-bottom:0;}
.tooltip{position:absolute;z-index:1020;display:block;visibility:visible;padding:5px;font-size:11px;opacity:0;filter:alpha(opacity=0);}.tooltip.in{opacity:0.8;filter:alpha(opacity=80);}
.tooltip.top{margin-top:-2px;}
.tooltip.right{margin-left:2px;}
.tooltip.bottom{margin-top:2px;}
.tooltip.left{margin-left:-2px;}
.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid #000000;}
.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-left:5px solid #000000;}
.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:5px solid #000000;}
.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-right:5px solid #000000;}
.tooltip-inner{max-width:200px;padding:3px 8px;color:#ffffff;text-align:center;text-decoration:none;background-color:#000000;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.tooltip-arrow{position:absolute;width:0;height:0;}
.popover{position:absolute;top:0;left:0;z-index:1010;display:none;padding:5px;}.popover.top{margin-top:-5px;}
.popover.right{margin-left:5px;}
.popover.bottom{margin-top:5px;}
.popover.left{margin-left:-5px;}
.popover.top .arrow{bottom:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid #000000;}
.popover.right .arrow{top:50%;left:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-right:5px solid #000000;}
.popover.bottom .arrow{top:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:5px solid #000000;}
.popover.left .arrow{top:50%;right:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-left:5px solid #000000;}
.popover .arrow{position:absolute;width:0;height:0;}
.popover-inner{padding:3px;width:280px;overflow:hidden;background:#000000;background:rgba(0, 0, 0, 0.8);-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-moz-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);}
.popover-title{padding:9px 15px;line-height:1;background-color:#f5f5f5;border-bottom:1px solid #eee;-webkit-border-radius:3px 3px 0 0;-moz-border-radius:3px 3px 0 0;border-radius:3px 3px 0 0;}
.popover-content{padding:14px;background-color:#ffffff;-webkit-border-radius:0 0 3px 3px;-moz-border-radius:0 0 3px 3px;border-radius:0 0 3px 3px;-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;}.popover-content p,.popover-content ul,.popover-content ol{margin-bottom:0;}
.thumbnails{margin-left:-20px;list-style:none;*zoom:1;}.thumbnails:before,.thumbnails:after{display:table;content:"";}
.thumbnails:after{clear:both;}
.thumbnails>li{float:left;margin:0 0 18px 20px;}
.thumbnail{display:block;padding:4px;line-height:1;border:1px solid #ddd;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);-moz-box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);}
a.thumbnail:hover{border-color:#0088cc;-webkit-box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);-moz-box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);}
.thumbnail>img{display:block;max-width:100%;margin-left:auto;margin-right:auto;}
.thumbnail .caption{padding:9px;}
.label{padding:1px 3px 2px;font-size:9.75px;font-weight:bold;color:#ffffff;text-transform:uppercase;background-color:#999999;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}
.label-important{background-color:#b94a48;}
.label-warning{background-color:#f89406;}
.label-success{background-color:#468847;}
.label-info{background-color:#3a87ad;}
@-webkit-keyframes progress-bar-stripes{from{background-position:0 0;} to{background-position:40px 0;}}@-moz-keyframes progress-bar-stripes{from{background-position:0 0;} to{background-position:40px 0;}}@keyframes progress-bar-stripes{from{background-position:0 0;} to{background-position:40px 0;}}.progress{overflow:hidden;height:18px;margin-bottom:18px;background-color:#f7f7f7;background-image:-moz-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:-ms-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#f5f5f5), to(#f9f9f9));background-image:-webkit-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:-o-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:linear-gradient(top, #f5f5f5, #f9f9f9);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#f5f5f5', endColorstr='#f9f9f9', GradientType=0);-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.progress .bar{width:0%;height:18px;color:#ffffff;font-size:12px;text-align:center;text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);background-color:#0e90d2;background-image:-moz-linear-gradient(top, #149bdf, #0480be);background-image:-ms-linear-gradient(top, #149bdf, #0480be);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#149bdf), to(#0480be));background-image:-webkit-linear-gradient(top, #149bdf, #0480be);background-image:-o-linear-gradient(top, #149bdf, #0480be);background-image:linear-gradient(top, #149bdf, #0480be);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#149bdf', endColorstr='#0480be', GradientType=0);-webkit-box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);-moz-box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;-webkit-transition:width 0.6s ease;-moz-transition:width 0.6s ease;-ms-transition:width 0.6s ease;-o-transition:width 0.6s ease;transition:width 0.6s ease;}
.progress-striped .bar{background-color:#62c462;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);-webkit-background-size:40px 40px;-moz-background-size:40px 40px;-o-background-size:40px 40px;background-size:40px 40px;}
.progress.active .bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-moz-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite;}
.progress-danger .bar{background-color:#dd514c;background-image:-moz-linear-gradient(top, #ee5f5b, #c43c35);background-image:-ms-linear-gradient(top, #ee5f5b, #c43c35);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ee5f5b), to(#c43c35));background-image:-webkit-linear-gradient(top, #ee5f5b, #c43c35);background-image:-o-linear-gradient(top, #ee5f5b, #c43c35);background-image:linear-gradient(top, #ee5f5b, #c43c35);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#c43c35', GradientType=0);}
.progress-danger.progress-striped .bar{background-color:#ee5f5b;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);}
.progress-success .bar{background-color:#5eb95e;background-image:-moz-linear-gradient(top, #62c462, #57a957);background-image:-ms-linear-gradient(top, #62c462, #57a957);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#62c462), to(#57a957));background-image:-webkit-linear-gradient(top, #62c462, #57a957);background-image:-o-linear-gradient(top, #62c462, #57a957);background-image:linear-gradient(top, #62c462, #57a957);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#62c462', endColorstr='#57a957', GradientType=0);}
.progress-success.progress-striped .bar{background-color:#62c462;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);}
.progress-info .bar{background-color:#4bb1cf;background-image:-moz-linear-gradient(top, #5bc0de, #339bb9);background-image:-ms-linear-gradient(top, #5bc0de, #339bb9);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#5bc0de), to(#339bb9));background-image:-webkit-linear-gradient(top, #5bc0de, #339bb9);background-image:-o-linear-gradient(top, #5bc0de, #339bb9);background-image:linear-gradient(top, #5bc0de, #339bb9);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#5bc0de', endColorstr='#339bb9', GradientType=0);}
.progress-info.progress-striped .bar{background-color:#5bc0de;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);}
.accordion{margin-bottom:18px;}
.accordion-group{margin-bottom:2px;border:1px solid #e5e5e5;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.accordion-heading{border-bottom:0;}
.accordion-heading .accordion-toggle{display:block;padding:8px 15px;}
.accordion-inner{padding:9px 15px;border-top:1px solid #e5e5e5;}
.carousel{position:relative;margin-bottom:18px;line-height:1;}
.carousel-inner{overflow:hidden;width:100%;position:relative;}
.carousel .item{display:none;position:relative;-webkit-transition:0.6s ease-in-out left;-moz-transition:0.6s ease-in-out left;-ms-transition:0.6s ease-in-out left;-o-transition:0.6s ease-in-out left;transition:0.6s ease-in-out left;}
.carousel .item>img{display:block;line-height:1;}
.carousel .active,.carousel .next,.carousel .prev{display:block;}
.carousel .active{left:0;}
.carousel .next,.carousel .prev{position:absolute;top:0;width:100%;}
.carousel .next{left:100%;}
.carousel .prev{left:-100%;}
.carousel .next.left,.carousel .prev.right{left:0;}
.carousel .active.left{left:-100%;}
.carousel .active.right{left:100%;}
.carousel-control{position:absolute;top:40%;left:15px;width:40px;height:40px;margin-top:-20px;font-size:60px;font-weight:100;line-height:30px;color:#ffffff;text-align:center;background:#222222;border:3px solid #ffffff;-webkit-border-radius:23px;-moz-border-radius:23px;border-radius:23px;opacity:0.5;filter:alpha(opacity=50);}.carousel-control.right{left:auto;right:15px;}
.carousel-control:hover{color:#ffffff;text-decoration:none;opacity:0.9;filter:alpha(opacity=90);}
.carousel-caption{position:absolute;left:0;right:0;bottom:0;padding:10px 15px 5px;background:#333333;background:rgba(0, 0, 0, 0.75);}
.carousel-caption h4,.carousel-caption p{color:#ffffff;}
.hero-unit{padding:60px;margin-bottom:30px;background-color:#f5f5f5;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;}.hero-unit h1{margin-bottom:0;font-size:60px;line-height:1;letter-spacing:-1px;}
.hero-unit p{font-size:18px;font-weight:200;line-height:27px;}
.pull-right{float:right;}
.pull-left{float:left;}
.hide{display:none;}
.show{display:block;}
.invisible{visibility:hidden;}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

-1722
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
!function(a){a(function(){"use strict",a.support.transition=function(){var b=document.body||document.documentElement,c=b.style,d=c.transition!==undefined||c.WebkitTransition!==undefined||c.MozTransition!==undefined||c.MsTransition!==undefined||c.OTransition!==undefined;return d&&{end:function(){var b="TransitionEnd";return a.browser.webkit?b="webkitTransitionEnd":a.browser.mozilla?b="transitionend":a.browser.opera&&(b="oTransitionEnd"),b}()}}()})}(window.jQuery),!function(a){"use strict";var b='[data-dismiss="alert"]',c=function(c){a(c).on("click",b,this.close)};c.prototype={constructor:c,close:function(b){function f(){e.remove(),e.trigger("closed")}var c=a(this),d=c.attr("data-target"),e;d||(d=c.attr("href"),d=d&&d.replace(/.*(?=#[^\s]*$)/,"")),e=a(d),e.trigger("close"),b&&b.preventDefault(),e.length||(e=c.hasClass("alert")?c:c.parent()),e.removeClass("in"),a.support.transition&&e.hasClass("fade")?e.on(a.support.transition.end,f):f()}},a.fn.alert=function(b){return this.each(function(){var d=a(this),e=d.data("alert");e||d.data("alert",e=new c(this)),typeof b=="string"&&e[b].call(d)})},a.fn.alert.Constructor=c,a(function(){a("body").on("click.alert.data-api",b,c.prototype.close)})}(window.jQuery),!function(a){"use strict";var b=function(b,c){this.$element=a(b),this.options=a.extend({},a.fn.button.defaults,c)};b.prototype={constructor:b,setState:function(a){var b="disabled",c=this.$element,d=c.data(),e=c.is("input")?"val":"html";a+="Text",d.resetText||c.data("resetText",c[e]()),c[e](d[a]||this.options[a]),setTimeout(function(){a=="loadingText"?c.addClass(b).attr(b,b):c.removeClass(b).removeAttr(b)},0)},toggle:function(){var a=this.$element.parent('[data-toggle="buttons-radio"]');a&&a.find(".active").removeClass("active"),this.$element.toggleClass("active")}},a.fn.button=function(c){return this.each(function(){var d=a(this),e=d.data("button"),f=typeof c=="object"&&c;e||d.data("button",e=new b(this,f)),c=="toggle"?e.toggle():c&&e.setState(c)})},a.fn.button.defaults={loadingText:"loading..."},a.fn.button.Constructor=b,a(function(){a("body").on("click.button.data-api","[data-toggle^=button]",function(b){a(b.target).button("toggle")})})}(window.jQuery),!function(a){"use strict";var b=function(b,c){this.$element=a(b),this.options=a.extend({},a.fn.carousel.defaults,c),this.options.slide&&this.slide(this.options.slide)};b.prototype={cycle:function(){return this.interval=setInterval(a.proxy(this.next,this),this.options.interval),this},to:function(b){var c=this.$element.find(".active"),d=c.parent().children(),e=d.index(c),f=this;if(b>d.length-1||b<0)return;return this.sliding?this.$element.one("slid",function(){f.to(b)}):e==b?this.pause().cycle():this.slide(b>e?"next":"prev",a(d[b]))},pause:function(){return clearInterval(this.interval),this},next:function(){if(this.sliding)return;return this.slide("next")},prev:function(){if(this.sliding)return;return this.slide("prev")},slide:function(b,c){var d=this.$element.find(".active"),e=c||d[b](),f=this.interval,g=b=="next"?"left":"right",h=b=="next"?"first":"last",i=this;return this.sliding=!0,f&&this.pause(),e=e.length?e:this.$element.find(".item")[h](),!a.support.transition&&this.$element.hasClass("slide")?(this.$element.trigger("slide"),d.removeClass("active"),e.addClass("active"),this.sliding=!1,this.$element.trigger("slid")):(e.addClass(b),e[0].offsetWidth,d.addClass(g),e.addClass(g),this.$element.trigger("slide"),this.$element.one(a.support.transition.end,function(){e.removeClass([b,g].join(" ")).addClass("active"),d.removeClass(["active",g].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger("slid")},0)})),f&&this.cycle(),this}},a.fn.carousel=function(c){return this.each(function(){var d=a(this),e=d.data("carousel"),f=typeof c=="object"&&c;e||d.data("carousel",e=new b(this,f)),typeof c=="number"?e.to(c):typeof c=="string"||(c=f.slide)?e[c]():e.cycle()})},a.fn.carousel.defaults={interval:5e3},a.fn.carousel.Constructor=b,a(function(){a("body").on("click.carousel.data-api","[data-slide]",function(b){var c=a(this),d,e=a(c.attr("data-target")||(d=c.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,"")),f=!e.data("modal")&&a.extend({},e.data(),c.data());e.carousel(f),b.preventDefault()})})}(window.jQuery),!function(a){"use strict";var b=function(b,c){this.$element=a(b),this.options=a.extend({},a.fn.collapse.defaults,c),this.options.parent&&(this.$parent=a(this.options.parent)),this.options.toggle&&this.toggle()};b.prototype={constructor:b,dimension:function(){var a=this.$element.hasClass("width");return a?"width":"height"},show:function(){var b=this.dimension(),c=a.camelCase(["scroll",b].join("-")),d=this.$parent&&this.$parent.find(".in"),e;d&&d.length&&(e=d.data("collapse"),d.collapse("hide"),e||d.data("collapse",null)),this.$element[b](0),this.transition("addClass","show","shown"),this.$element[b](this.$element[0][c])},hide:function(){var a=this.dimension();this.reset(this.$element[a]()),this.transition("removeClass","hide","hidden"),this.$element[a](0)},reset:functLine truncated
Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 B

-4
View File
@@ -1,4 +0,0 @@
/*! jQuery v1.7.1 jquery.com | jquery.org/license */
(function(a,b){function cy(a){return f.isWindow(a)?a:a.nodeType===9?a.defaultView||a.parentWindow:!1}function cv(a){if(!ck[a]){var b=c.body,d=f("<"+a+">").appendTo(b),e=d.css("display");d.remove();if(e==="none"||e===""){cl||(cl=c.createElement("iframe"),cl.frameBorder=cl.width=cl.height=0),b.appendChild(cl);if(!cm||!cl.createElement)cm=(cl.contentWindow||cl.contentDocument).document,cm.write((c.compatMode==="CSS1Compat"?"<!doctype html>":"")+"<html><body>"),cm.close();d=cm.createElement(a),cm.body.appendChild(d),e=f.css(d,"display"),b.removeChild(cl)}ck[a]=e}return ck[a]}function cu(a,b){var c={};f.each(cq.concat.apply([],cq.slice(0,b)),function(){c[this]=a});return c}function ct(){cr=b}function cs(){setTimeout(ct,0);return cr=f.now()}function cj(){try{return new a.ActiveXObject("Microsoft.XMLHTTP")}catch(b){}}function ci(){try{return new a.XMLHttpRequest}catch(b){}}function cc(a,c){a.dataFilter&&(c=a.dataFilter(c,a.dataType));var d=a.dataTypes,e={},g,h,i=d.length,j,k=d[0],l,m,n,o,p;for(g=1;g<i;g++){if(g===1)for(h in a.converters)typeof h=="string"&&(e[h.toLowerCase()]=a.converters[h]);l=k,k=d[g];if(k==="*")k=l;else if(l!=="*"&&l!==k){m=l+" "+k,n=e[m]||e["* "+k];if(!n){p=b;for(o in e){j=o.split(" ");if(j[0]===l||j[0]==="*"){p=e[j[1]+" "+k];if(p){o=e[o],o===!0?n=p:p===!0&&(n=o);break}}}}!n&&!p&&f.error("No conversion from "+m.replace(" "," to ")),n!==!0&&(c=n?n(c):p(o(c)))}}return c}function cb(a,c,d){var e=a.contents,f=a.dataTypes,g=a.responseFields,h,i,j,k;for(i in g)i in d&&(c[g[i]]=d[i]);while(f[0]==="*")f.shift(),h===b&&(h=a.mimeType||c.getResponseHeader("content-type"));if(h)for(i in e)if(e[i]&&e[i].test(h)){f.unshift(i);break}if(f[0]in d)j=f[0];else{for(i in d){if(!f[0]||a.converters[i+" "+f[0]]){j=i;break}k||(k=i)}j=j||k}if(j){j!==f[0]&&f.unshift(j);return d[j]}}function ca(a,b,c,d){if(f.isArray(b))f.each(b,function(b,e){c||bE.test(a)?d(a,e):ca(a+"["+(typeof e=="object"||f.isArray(e)?b:"")+"]",e,c,d)});else if(!c&&b!=null&&typeof b=="object")for(var e in b)ca(a+"["+e+"]",b[e],c,d);else d(a,b)}function b_(a,c){var d,e,g=f.ajaxSettings.flatOptions||{};for(d in c)c[d]!==b&&((g[d]?a:e||(e={}))[d]=c[d]);e&&f.extend(!0,a,e)}function b$(a,c,d,e,f,g){f=f||c.dataTypes[0],g=g||{},g[f]=!0;var h=a[f],i=0,j=h?h.length:0,k=a===bT,l;for(;i<j&&(k||!l);i++)l=h[i](c,d,e),typeof l=="string"&&(!k||g[l]?l=b:(c.dataTypes.unshift(l),l=b$(a,c,d,e,l,g)));(k||!l)&&!g["*"]&&(l=b$(a,c,d,e,"*",g));return l}function bZ(a){return function(b,c){typeof b!="string"&&(c=b,b="*");if(f.isFunction(c)){var d=b.toLowerCase().split(bP),e=0,g=d.length,h,i,j;for(;e<g;e++)h=d[e],j=/^\+/.test(h),j&&(h=h.substr(1)||"*"),i=a[h]=a[h]||[],i[j?"unshift":"push"](c)}}}function bC(a,b,c){var d=b==="width"?a.offsetWidth:a.offsetHeight,e=b==="width"?bx:by,g=0,h=e.length;if(d>0){if(c!=="border")for(;g<h;g++)c||(d-=parseFloat(f.css(a,"padding"+e[g]))||0),c==="margin"?d+=parseFloat(f.css(a,c+e[g]))||0:d-=parseFloat(f.css(a,"border"+e[g]+"Width"))||0;return d+"px"}d=bz(a,b,b);if(d<0||d==null)d=a.style[b]||0;d=parseFloat(d)||0;if(c)for(;g<h;g++)d+=parseFloat(f.css(a,"padding"+e[g]))||0,c!=="padding"&&(d+=parseFloat(f.css(a,"border"+e[g]+"Width"))||0),c==="margin"&&(d+=parseFloat(f.css(a,c+e[g]))||0);return d+"px"}function bp(a,b){b.src?f.ajax({url:b.src,async:!1,dataType:"script"}):f.globalEval((b.text||b.textContent||b.innerHTML||"").replace(bf,"/*$0*/")),b.parentNode&&b.parentNode.removeChild(b)}function bo(a){var b=c.createElement("div");bh.appendChild(b),b.innerHTML=a.outerHTML;return b.firstChild}function bn(a){var b=(a.nodeName||"").toLowerCase();b==="input"?bm(a):b!=="script"&&typeof a.getElementsByTagName!="undefined"&&f.grep(a.getElementsByTagName("input"),bm)}function bm(a){if(a.type==="checkbox"||a.type==="radio")a.defaultChecked=a.checked}function bl(a){return typeof a.getElementsByTagName!="undefined"?a.getElementsByTagName("*"):typeof a.querySelectorAll!="undefined"?a.querySelectorAll("*"):[]}function bk(a,b){var c;if(b.nodeType===1){b.clearAttributes&&b.clearAttributes(),b.mergeAttributes&&b.mergeAttributes(a),c=b.nodeName.toLowerCase();if(c==="object")b.outerHTML=a.outerHTML;else if(c!=="input"||a.type!=="checkbox"&&a.type!=="radio"){if(c==="option")b.selected=a.defaultSelected;else if(c==="input"||c==="textarea")b.defaultValue=a.defaultValue}else a.checked&&(b.defaultChecked=b.checked=a.checked),b.value!==a.value&&(b.value=a.value);b.removeAttribute(f.expando)}}function bj(a,b){if(b.nodeType===1&&!!f.hasData(a)){var c,d,e,g=f._data(a),h=f._data(b,g),i=g.events;if(i){delete h.handle,h.events={};for(c in i)for(d=0,e=i[c].length;d<e;d++)f.event.add(b,c+(i[c][d].namespace?".":"")+i[c][d].namespace,i[c][d],i[c][d].data)}h.data&&(h.data=f.extend({},h.data))}}function bi(a,b){return f.nodeName(a,"table")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function U(a){var b=V.split("|"),c=a.createDocumentFragment();if(c.createElement)while(b.length)c.createElement(b.pop());return c}function T(a,b,c){b=b||0;iLine truncated
f.event={add:function(a,c,d,e,g){var h,i,j,k,l,m,n,o,p,q,r,s;if(!(a.nodeType===3||a.nodeType===8||!c||!d||!(h=f._data(a)))){d.handler&&(p=d,d=p.handler),d.guid||(d.guid=f.guid++),j=h.events,j||(h.events=j={}),i=h.handle,i||(h.handle=i=function(a){return typeof f!="undefined"&&(!a||f.event.triggered!==a.type)?f.event.dispatch.apply(i.elem,arguments):b},i.elem=a),c=f.trim(I(c)).split(" ");for(k=0;k<c.length;k++){l=A.exec(c[k])||[],m=l[1],n=(l[2]||"").split(".").sort(),s=f.event.special[m]||{},m=(g?s.delegateType:s.bindType)||m,s=f.event.special[m]||{},o=f.extend({type:m,origType:l[1],data:e,handler:d,guid:d.guid,selector:g,quick:G(g),namespace:n.join(".")},p),r=j[m];if(!r){r=j[m]=[],r.delegateCount=0;if(!s.setup||s.setup.call(a,e,n,i)===!1)a.addEventListener?a.addEventListener(m,i,!1):a.attachEvent&&a.attachEvent("on"+m,i)}s.add&&(s.add.call(a,o),o.handler.guid||(o.handler.guid=d.guid)),g?r.splice(r.delegateCount++,0,o):r.push(o),f.event.global[m]=!0}a=null}},global:{},remove:function(a,b,c,d,e){var g=f.hasData(a)&&f._data(a),h,i,j,k,l,m,n,o,p,q,r,s;if(!!g&&!!(o=g.events)){b=f.trim(I(b||"")).split(" ");for(h=0;h<b.length;h++){i=A.exec(b[h])||[],j=k=i[1],l=i[2];if(!j){for(j in o)f.event.remove(a,j+b[h],c,d,!0);continue}p=f.event.special[j]||{},j=(d?p.delegateType:p.bindType)||j,r=o[j]||[],m=r.length,l=l?new RegExp("(^|\\.)"+l.split(".").sort().join("\\.(?:.*\\.)?")+"(\\.|$)"):null;for(n=0;n<r.length;n++)s=r[n],(e||k===s.origType)&&(!c||c.guid===s.guid)&&(!l||l.test(s.namespace))&&(!d||d===s.selector||d==="**"&&s.selector)&&(r.splice(n--,1),s.selector&&r.delegateCount--,p.remove&&p.remove.call(a,s));r.length===0&&m!==r.length&&((!p.teardown||p.teardown.call(a,l)===!1)&&f.removeEvent(a,j,g.handle),delete o[j])}f.isEmptyObject(o)&&(q=g.handle,q&&(q.elem=null),f.removeData(a,["events","handle"],!0))}},customEvent:{getData:!0,setData:!0,changeData:!0},trigger:function(c,d,e,g){if(!e||e.nodeType!==3&&e.nodeType!==8){var h=c.type||c,i=[],j,k,l,m,n,o,p,q,r,s;if(E.test(h+f.event.triggered))return;h.indexOf("!")>=0&&(h=h.slice(0,-1),k=!0),h.indexOf(".")>=0&&(i=h.split("."),h=i.shift(),i.sort());if((!e||f.event.customEvent[h])&&!f.event.global[h])return;c=typeof c=="object"?c[f.expando]?c:new f.Event(h,c):new f.Event(h),c.type=h,c.isTrigger=!0,c.exclusive=k,c.namespace=i.join("."),c.namespace_re=c.namespace?new RegExp("(^|\\.)"+i.join("\\.(?:.*\\.)?")+"(\\.|$)"):null,o=h.indexOf(":")<0?"on"+h:"";if(!e){j=f.cache;for(l in j)j[l].events&&j[l].events[h]&&f.event.trigger(c,d,j[l].handle.elem,!0);return}c.result=b,c.target||(c.target=e),d=d!=null?f.makeArray(d):[],d.unshift(c),p=f.event.special[h]||{};if(p.trigger&&p.trigger.apply(e,d)===!1)return;r=[[e,p.bindType||h]];if(!g&&!p.noBubble&&!f.isWindow(e)){s=p.delegateType||h,m=E.test(s+h)?e:e.parentNode,n=null;for(;m;m=m.parentNode)r.push([m,s]),n=m;n&&n===e.ownerDocument&&r.push([n.defaultView||n.parentWindow||a,s])}for(l=0;l<r.length&&!c.isPropagationStopped();l++)m=r[l][0],c.type=r[l][1],q=(f._data(m,"events")||{})[c.type]&&f._data(m,"handle"),q&&q.apply(m,d),q=o&&m[o],q&&f.acceptData(m)&&q.apply(m,d)===!1&&c.preventDefault();c.type=h,!g&&!c.isDefaultPrevented()&&(!p._default||p._default.apply(e.ownerDocument,d)===!1)&&(h!=="click"||!f.nodeName(e,"a"))&&f.acceptData(e)&&o&&e[h]&&(h!=="focus"&&h!=="blur"||c.target.offsetWidth!==0)&&!f.isWindow(e)&&(n=e[o],n&&(e[o]=null),f.event.triggered=h,e[h](),f.event.triggered=b,n&&(e[o]=n));return c.result}},dispatch:function(c){c=f.event.fix(c||a.event);var d=(f._data(this,"events")||{})[c.type]||[],e=d.delegateCount,g=[].slice.call(arguments,0),h=!c.exclusive&&!c.namespace,i=[],j,k,l,m,n,o,p,q,r,s,t;g[0]=c,c.delegateTarget=this;if(e&&!c.target.disabled&&(!c.button||c.type!=="click")){m=f(this),m.context=this.ownerDocument||this;for(l=c.target;l!=this;l=l.parentNode||this){o={},q=[],m[0]=l;for(j=0;j<e;j++)r=d[j],s=r.selector,o[s]===b&&(o[s]=r.quick?H(l,r.quick):m.is(s)),o[s]&&q.push(r);q.length&&i.push({elem:l,matches:q})}}d.length>e&&i.push({elem:this,matches:d.slice(e)});for(j=0;j<i.length&&!c.isPropagationStopped();j++){p=i[j],c.currentTarget=p.elem;for(k=0;k<p.matches.length&&!c.isImmediatePropagationStopped();k++){r=p.matches[k];if(h||!c.namespace&&!r.namespace||c.namespace_re&&c.namespace_re.test(r.namespace))c.data=r.data,c.handleObj=r,n=((f.event.special[r.origType]||{}).handle||r.handler).apply(p.elem,g),n!==b&&(c.result=n,n===!1&&(c.preventDefault(),c.stopPropagation()))}}return c.result},props:"attrChange attrName relatedNode srcElement altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(a,b){a.which==null&&(a.which=b.charCode!=null?b.charCode:b.keyCode);return a}},mouseHooks:{props:"button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(a,d){var e,f,g,h=d.button,i=d.fromElement;a.pageX==null&&d.cLine truncated
{for(var a=0,b;(b=this[a])!=null;a++){b.nodeType===1&&f.cleanData(b.getElementsByTagName("*"));while(b.firstChild)b.removeChild(b.firstChild)}return this},clone:function(a,b){a=a==null?!1:a,b=b==null?a:b;return this.map(function(){return f.clone(this,a,b)})},html:function(a){if(a===b)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(W,""):null;if(typeof a=="string"&&!ba.test(a)&&(f.support.leadingWhitespace||!X.test(a))&&!bg[(Z.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(Y,"<$1></$2>");try{for(var c=0,d=this.length;c<d;c++)this[c].nodeType===1&&(f.cleanData(this[c].getElementsByTagName("*")),this[c].innerHTML=a)}catch(e){this.empty().append(a)}}else f.isFunction(a)?this.each(function(b){var c=f(this);c.html(a.call(this,b,c.html()))}):this.empty().append(a);return this},replaceWith:function(a){if(this[0]&&this[0].parentNode){if(f.isFunction(a))return this.each(function(b){var c=f(this),d=c.html();c.replaceWith(a.call(this,b,d))});typeof a!="string"&&(a=f(a).detach());return this.each(function(){var b=this.nextSibling,c=this.parentNode;f(this).remove(),b?f(b).before(a):f(c).append(a)})}return this.length?this.pushStack(f(f.isFunction(a)?a():a),"replaceWith",a):this},detach:function(a){return this.remove(a,!0)},domManip:function(a,c,d){var e,g,h,i,j=a[0],k=[];if(!f.support.checkClone&&arguments.length===3&&typeof j=="string"&&bd.test(j))return this.each(function(){f(this).domManip(a,c,d,!0)});if(f.isFunction(j))return this.each(function(e){var g=f(this);a[0]=j.call(this,e,c?g.html():b),g.domManip(a,c,d)});if(this[0]){i=j&&j.parentNode,f.support.parentNode&&i&&i.nodeType===11&&i.childNodes.length===this.length?e={fragment:i}:e=f.buildFragment(a,this,k),h=e.fragment,h.childNodes.length===1?g=h=h.firstChild:g=h.firstChild;if(g){c=c&&f.nodeName(g,"tr");for(var l=0,m=this.length,n=m-1;l<m;l++)d.call(c?bi(this[l],g):this[l],e.cacheable||m>1&&l<n?f.clone(h,!0,!0):h)}k.length&&f.each(k,bp)}return this}}),f.buildFragment=function(a,b,d){var e,g,h,i,j=a[0];b&&b[0]&&(i=b[0].ownerDocument||b[0]),i.createDocumentFragment||(i=c),a.length===1&&typeof j=="string"&&j.length<512&&i===c&&j.charAt(0)==="<"&&!bb.test(j)&&(f.support.checkClone||!bd.test(j))&&(f.support.html5Clone||!bc.test(j))&&(g=!0,h=f.fragments[j],h&&h!==1&&(e=h)),e||(e=i.createDocumentFragment(),f.clean(a,i,e,d)),g&&(f.fragments[j]=h?e:1);return{fragment:e,cacheable:g}},f.fragments={},f.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){f.fn[a]=function(c){var d=[],e=f(c),g=this.length===1&&this[0].parentNode;if(g&&g.nodeType===11&&g.childNodes.length===1&&e.length===1){e[b](this[0]);return this}for(var h=0,i=e.length;h<i;h++){var j=(h>0?this.clone(!0):this).get();f(e[h])[b](j),d=d.concat(j)}return this.pushStack(d,a,e.selector)}}),f.extend({clone:function(a,b,c){var d,e,g,h=f.support.html5Clone||!bc.test("<"+a.nodeName)?a.cloneNode(!0):bo(a);if((!f.support.noCloneEvent||!f.support.noCloneChecked)&&(a.nodeType===1||a.nodeType===11)&&!f.isXMLDoc(a)){bk(a,h),d=bl(a),e=bl(h);for(g=0;d[g];++g)e[g]&&bk(d[g],e[g])}if(b){bj(a,h);if(c){d=bl(a),e=bl(h);for(g=0;d[g];++g)bj(d[g],e[g])}}d=e=null;return h},clean:function(a,b,d,e){var g;b=b||c,typeof b.createElement=="undefined"&&(b=b.ownerDocument||b[0]&&b[0].ownerDocument||c);var h=[],i;for(var j=0,k;(k=a[j])!=null;j++){typeof k=="number"&&(k+="");if(!k)continue;if(typeof k=="string")if(!_.test(k))k=b.createTextNode(k);else{k=k.replace(Y,"<$1></$2>");var l=(Z.exec(k)||["",""])[1].toLowerCase(),m=bg[l]||bg._default,n=m[0],o=b.createElement("div");b===c?bh.appendChild(o):U(b).appendChild(o),o.innerHTML=m[1]+k+m[2];while(n--)o=o.lastChild;if(!f.support.tbody){var p=$.test(k),q=l==="table"&&!p?o.firstChild&&o.firstChild.childNodes:m[1]==="<table>"&&!p?o.childNodes:[];for(i=q.length-1;i>=0;--i)f.nodeName(q[i],"tbody")&&!q[i].childNodes.length&&q[i].parentNode.removeChild(q[i])}!f.support.leadingWhitespace&&X.test(k)&&o.insertBefore(b.createTextNode(X.exec(k)[0]),o.firstChild),k=o.childNodes}var r;if(!f.support.appendChecked)if(k[0]&&typeof (r=k.length)=="number")for(i=0;i<r;i++)bn(k[i]);else bn(k);k.nodeType?h.push(k):h=f.merge(h,k)}if(d){g=function(a){return!a.type||be.test(a.type)};for(j=0;h[j];j++)if(e&&f.nodeName(h[j],"script")&&(!h[j].type||h[j].type.toLowerCase()==="text/javascript"))e.push(h[j].parentNode?h[j].parentNode.removeChild(h[j]):h[j]);else{if(h[j].nodeType===1){var s=f.grep(h[j].getElementsByTagName("script"),g);h.splice.apply(h,[j+1,0].concat(s))}d.appendChild(h[j])}}return h},cleanData:function(a){var b,c,d=f.cache,e=f.event.special,g=f.support.deleteExpando;for(var h=0,i;(i=a[h])!=null;h++){if(i.nodeName&&f.noData[i.nodeName.toLowerCase()])continue;c=i[f.expando];if(c){b=d[c];if(b&&b.events){for(var j in b.events)e[j]?f.event.remove(i,j):f.removeEvent(i,j,b.handle);b.handle&&(b.handle.elem=null)}g?delete i[f.expando]:i.removeAttribute&&i.removeAttribute(f.expando),delete d[c]}}}});var bq=/alpha\([^)]*\)/i,br=/opacitLine truncated
View File
Whitespace-only changes.
+55
View File
@@ -0,0 +1,55 @@
import pytest
from django.core.management import call_command
from mdb.models import (
Domain,
DomainAAAARecord,
DomainARecord,
DomainCnameRecord,
DomainTxtRecord,
MailExchange,
Nameserver,
)
from mdb.services.zone_sync import ZoneSyncService
@pytest.fixture
def domain(db):
call_command("loaddata", "test_data", verbosity=0)
return Domain.objects.get(domain_name="edb.technology")
@pytest.fixture
def service(tmp_path):
svc = ZoneSyncService(debug=True)
svc.checkzone_dir = str(tmp_path)
return svc
@pytest.fixture
def db_domain(db, tmp_path):
domain = Domain.objects.create(
domain_name="example.org",
domain_soa="ns1.example.org",
domain_admin="admin@example.org",
domain_ipaddr="1.2.3.4",
domain_ip6addr="2001:db8::1",
domain_filename=str(tmp_path / "db.example.org"),
domain_ttl=3600,
domain_serial=2024010101,
)
ns1 = Nameserver.objects.create(hostname="ns1.example.org")
ns2 = Nameserver.objects.create(hostname="ns2.example.org")
domain.domain_nameservers.set([ns1, ns2])
mx = MailExchange.objects.create(priority=10, hostname="mail.example.org")
domain.domain_mailexchanges.set([mx])
DomainARecord.objects.create(domain=domain, name="ns1", target="1.2.3.4")
DomainARecord.objects.create(domain=domain, name="ns2", target="5.6.7.8")
DomainARecord.objects.create(domain=domain, name="mail", target="9.10.11.12")
DomainARecord.objects.create(domain=domain, name="www", target="1.2.3.4")
DomainAAAARecord.objects.create(domain=domain, name="www", target="2001:db8::1")
DomainCnameRecord.objects.create(
domain=domain, name="ftp", target="www.example.org."
)
DomainTxtRecord.objects.create(domain=domain, name="@", target='"v=spf1 mx ~all"')
return domain
+103
View File
@@ -0,0 +1,103 @@
import pytest
from django.contrib.auth.models import User
from django.core.management import call_command
from django.urls import reverse
from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient
from mdb.models import Host
@pytest.fixture
def db_with_fixtures(db):
call_command("loaddata", "test_data", verbosity=0)
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def user_with_token(db_with_fixtures):
user = User.objects.create(username="admin")
token = Token.objects.create(user=user)
return user, token
@pytest.fixture
def host(db_with_fixtures):
return Host.objects.first()
@pytest.mark.django_db
def test_validate_puppet_host_by_secret(api_client, user_with_token, host):
_, token = user_with_token
data = {
"pxe_key": host.pxe_key,
"certname": f"{host.hostname}.{host.interface_set.first().domain.domain_name}",
}
api_client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
res = api_client.post(reverse("api-validate-host-secret"), data, format="json")
assert res.status_code == 200, res.content
assert not Host.objects.get(pk=host.pk).pxe_installable
def post_validate(api_client, token, certname, pxe_key):
api_client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
return api_client.post(
reverse("api-validate-host-secret"),
{"certname": certname, "pxe_key": pxe_key},
format="json",
)
@pytest.mark.parametrize(
("change", "certname", "error"),
[
({}, "nosuchhost.edb.technology", "not found"),
({}, "tut.nosuchdomain.example", "not found"),
({"pxe_installable": False}, "tut.edb.technology", "not installable"),
({"pxe_key": ""}, "tut.edb.technology", "has no pxe_key"),
],
)
def test_validate_rejects(api_client, user_with_token, host, change, certname, error):
_, token = user_with_token
Host.objects.filter(pk=host.pk).update(**change)
res = post_validate(api_client, token, certname, host.pxe_key)
assert res.status_code == 400
assert error in str(res.content)
def test_validate_rejects_wrong_key(api_client, user_with_token, host):
_, token = user_with_token
res = post_validate(api_client, token, "tut.edb.technology", "wrong")
assert res.status_code == 400
assert "does not match" in str(res.content)
# A wrong guess must not use up the installation.
host.refresh_from_db()
assert host.pxe_installable
assert host.pxe_key
def test_validate_key_works_only_once(api_client, user_with_token, host):
_, token = user_with_token
key = host.pxe_key
assert (
post_validate(api_client, token, "tut.edb.technology", key).status_code == 200
)
assert (
post_validate(api_client, token, "tut.edb.technology", key).status_code == 400
)
def test_validate_requires_a_token(api_client, host):
res = api_client.post(
reverse("api-validate-host-secret"),
{"certname": "tut.edb.technology", "pxe_key": host.pxe_key},
format="json",
)
assert res.status_code == 401
assert Host.objects.get(pk=host.pk).pxe_installable
+121
View File
@@ -0,0 +1,121 @@
from unittest.mock import MagicMock, patch
import pytest
from mdb.services.dhcp_sync import DhcpSyncService
def make_config(serial=2, active_serial=1):
config = MagicMock()
config.serial = serial
config.active_serial = active_serial
config.dhcpd_configuration.return_value = (
"# dhcpd config\nsubnet 10.0.0.0 netmask 255.255.255.0 {}\n"
)
return config
@pytest.fixture
def service():
return DhcpSyncService()
# --- needs_update ---
def test_needs_update_true_when_serials_differ(service):
config = make_config(serial=2, active_serial=1)
assert service.needs_update(config) is True
def test_needs_update_false_when_serials_match(service):
config = make_config(serial=1, active_serial=1)
assert service.needs_update(config) is False
def test_needs_update_true_when_force_even_if_serials_match():
svc = DhcpSyncService(force=True)
config = make_config(serial=1, active_serial=1)
assert svc.needs_update(config) is True
# --- calculate_diff ---
def test_calculate_diff_identical_returns_empty(service):
config = make_config()
assert service.calculate_diff(config, "line\n", "line\n") == ""
def test_calculate_diff_format(service):
config = make_config(serial=2, active_serial=1)
diff = service.calculate_diff(config, "new line\n", "old line\n")
assert "*** 1" in diff
assert "--- 2" in diff
assert "! old line" in diff
assert "! new line" in diff
# --- write_config ---
def test_write_config_creates_file_with_config_contents(service, tmp_path):
config = make_config()
output_path = str(tmp_path / "dhcpd.conf")
service.write_config(config, output_path)
with open(output_path) as f:
assert f.read() == config.dhcpd_configuration.return_value
def test_write_config_updates_active_serial(service, tmp_path):
config = make_config(serial=5, active_serial=3)
output_path = str(tmp_path / "dhcpd.conf")
service.write_config(config, output_path)
assert config.active_serial == 5
config.save.assert_called_once()
def test_write_config_returns_old_serial(service, tmp_path):
config = make_config(serial=2, active_serial=1)
output_path = str(tmp_path / "dhcpd.conf")
old_serial, diff = service.write_config(config, output_path)
assert old_serial == 1
def test_write_config_diff_reflects_changes(service, tmp_path):
output_path = str(tmp_path / "dhcpd.conf")
with open(output_path, "w") as f:
f.write("old content\n")
config = make_config(serial=2, active_serial=1)
config.dhcpd_configuration.return_value = "new content\n"
old_serial, diff = service.write_config(config, output_path)
assert "old content" in diff
assert "new content" in diff
# --- restart_service ---
def test_restart_service_returns_true_on_success(service):
with patch("mdb.services.dhcp_sync.getstatusoutput", return_value=(0, "")):
success, output = service.restart_service()
assert success is True
assert output == ""
def test_restart_service_returns_false_on_failure(service):
with patch(
"mdb.services.dhcp_sync.getstatusoutput", return_value=(1, "Job failed")
):
success, output = service.restart_service()
assert success is False
assert output == "Job failed"
+135
View File
@@ -0,0 +1,135 @@
from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
from mdb.management.commands.dhcp_sync import Command as CommandClass
@pytest.fixture
def cmd():
c = CommandClass()
c.stdout = StringIO()
c.stderr = StringIO()
return c
def make_service(serial=2, active_serial=1, restart_success=True, restart_output=""):
svc = MagicMock()
svc.force = False
svc.restart_command = "/usr/sbin/service isc-dhcp-server restart"
config = MagicMock()
config.serial = serial
config.active_serial = active_serial
config.dhcpd_configuration.return_value = "# dhcpd config\n"
svc.get_config.return_value = config
svc.needs_update.return_value = serial != active_serial
svc.write_config.return_value = (active_serial, "- old\n+ new")
svc.restart_service.return_value = (restart_success, restart_output)
return svc, config
# --- handle: nothing to do ---
def test_handle_prints_nothing_to_do_when_no_update_needed(cmd):
svc, config = make_service(serial=1, active_serial=1)
svc.needs_update.return_value = False
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=None, force=False, restart=False)
assert "Nothing to do" in cmd.stdout.getvalue()
# --- handle: stdout mode ---
def test_handle_prints_config_to_stdout_when_no_output_path(cmd):
svc, config = make_service()
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=None, force=False, restart=False)
assert "# dhcpd config" in cmd.stdout.getvalue()
assert "Not writing to file" in cmd.stdout.getvalue()
def test_handle_does_not_write_file_when_no_output_path(cmd):
svc, config = make_service()
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=None, force=False, restart=False)
svc.write_config.assert_not_called()
# --- handle: file mode ---
def test_handle_calls_write_config_when_output_given(cmd, tmp_path):
svc, config = make_service()
output_path = str(tmp_path / "dhcpd.conf")
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=output_path, force=False, restart=False)
svc.write_config.assert_called_once_with(config, output_path)
def test_handle_does_not_restart_when_restart_false(cmd, tmp_path):
svc, config = make_service()
output_path = str(tmp_path / "dhcpd.conf")
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
with patch.object(cmd, "restart_and_notify") as mock_restart:
cmd.handle(output=output_path, force=False, restart=False)
mock_restart.assert_not_called()
def test_handle_calls_restart_and_notify_when_restart_true(cmd, tmp_path):
svc, config = make_service()
output_path = str(tmp_path / "dhcpd.conf")
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
with patch.object(cmd, "restart_and_notify") as mock_restart:
cmd.handle(output=output_path, force=False, restart=True)
mock_restart.assert_called_once()
# --- restart_and_notify ---
def test_restart_and_notify_success_writes_ok(cmd):
svc, config = make_service(restart_success=True)
with patch("mdb.management.commands.dhcp_sync.discord"):
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
assert "ok" in cmd.stdout.getvalue()
def test_restart_and_notify_success_calls_discord(cmd):
svc, config = make_service(restart_success=True)
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
mock_discord.notify.assert_called_once()
assert "✅" in mock_discord.notify.call_args.kwargs["subject"]
def test_restart_and_notify_success_passes_diff_as_preformatted(cmd):
svc, config = make_service(restart_success=True)
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
assert mock_discord.notify.call_args.kwargs["preformatted"] == "- old\n+ new"
def test_restart_and_notify_failure_writes_fail_and_exits(cmd):
svc, config = make_service(restart_success=False, restart_output="Job failed")
with patch("mdb.management.commands.dhcp_sync.discord"):
with patch("getpass.getuser", return_value="testuser"):
with pytest.raises(SystemExit) as exc:
cmd.restart_and_notify(svc, config, old_serial=1, diff="")
assert exc.value.code == 1
assert "fail" in cmd.stdout.getvalue()
def test_restart_and_notify_failure_discord_includes_command_and_output(cmd):
svc, config = make_service(restart_success=False, restart_output="Job failed")
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
with patch("getpass.getuser", return_value="testuser"):
with pytest.raises(SystemExit):
cmd.restart_and_notify(svc, config, old_serial=1, diff="")
subject = mock_discord.notify.call_args.kwargs["subject"]
message = mock_discord.notify.call_args.kwargs["message"]
assert "⚠️" in subject
assert "isc-dhcp-server" in message
assert "Job failed" in message
+82
View File
@@ -0,0 +1,82 @@
from unittest.mock import MagicMock, patch
import pytest
from mdb.services import discord
@pytest.fixture(autouse=True)
def webhook_url(settings):
settings.DISCORD_NOTIFICATION_WEBHOOK = "https://discord.example/webhook"
def test_notify_does_nothing_without_webhook(settings):
settings.DISCORD_NOTIFICATION_WEBHOOK = None
with patch("mdb.services.discord._send_json") as mock:
discord.notify("Subject", "message")
mock.assert_not_called()
def test_notify_sends_json_for_short_content():
with patch("mdb.services.discord._send_json") as mock:
discord.notify("Subject", "message")
mock.assert_called_once()
_, content = mock.call_args.args
assert "**Subject**" in content
assert "message" in content
def test_notify_includes_preformatted_as_diff_code_block():
with patch("mdb.services.discord._send_json") as mock:
discord.notify("Subject", "message", preformatted="- old\n+ new")
_, content = mock.call_args.args
assert "```diff\n- old\n+ new\n```" in content
def test_notify_sends_file_when_content_exceeds_limit():
long_diff = "x" * discord.MAX_CONTENT_LENGTH
with patch("mdb.services.discord._send_as_file") as mock:
discord.notify("Subject", "message", preformatted=long_diff)
mock.assert_called_once()
def test_notify_sends_json_when_content_fits():
with patch("mdb.services.discord._send_json") as mock:
discord.notify("Subject", "short message")
mock.assert_called_once()
def test_send_json_posts_with_mdb_username():
mock_resp = MagicMock()
with patch("requests.post", return_value=mock_resp) as mock_post:
discord._send_json("https://discord.example/webhook", "hello")
payload = mock_post.call_args.kwargs["json"]
assert payload["username"] == "MDB"
assert payload["content"] == "hello"
mock_resp.raise_for_status.assert_called_once()
def test_send_as_file_posts_multipart_with_attachment():
mock_resp = MagicMock()
with patch("requests.post", return_value=mock_resp) as mock_post:
discord._send_as_file(
"https://discord.example/webhook", "Subject", "full content"
)
assert mock_post.call_args.kwargs.get("files") or "files" in str(
mock_post.call_args
)
mock_resp.raise_for_status.assert_called_once()
def test_send_as_file_content_mentions_attachment():
import json
mock_resp = MagicMock()
with patch("requests.post", return_value=mock_resp) as mock_post:
discord._send_as_file(
"https://discord.example/webhook", "MySubject", "full content"
)
data = mock_post.call_args.kwargs["data"]
payload = json.loads(data["payload_json"])
assert "MySubject" in payload["content"]
assert "attachment" in payload["content"]
+64
View File
@@ -0,0 +1,64 @@
import pytest
from django.core.management import call_command
from mdb.forms import InterfaceForm
from mdb.models import Host, Interface, Ip4Address
@pytest.fixture
def fixtures(db):
call_command("loaddata", "test_data", verbosity=0)
def form(instance=None, **overrides):
data = {
"name": "eth1",
"macaddr": "00:11:22:33:44:55",
"host": Host.objects.get().pk,
"domain": 1,
"ip4address": Ip4Address.objects.get(address="10.0.3.10").pk,
"pxe_filename": "",
} | overrides
return InterfaceForm(data=data, instance=instance)
def test_valid(fixtures):
f = form()
assert f.is_valid(), f.errors
def test_invalid_mac_is_a_form_error(fixtures):
# Used to raise KeyError, a 500 in the admin.
f = form(macaddr="not a mac")
assert not f.is_valid()
assert "macaddr" in f.errors
def test_interface_without_ip4address(fixtures):
f = form(ip4address="")
assert f.is_valid(), f.errors
@pytest.mark.parametrize("macaddr", ["f0:de:f1:58:be:1a", "F0:DE:F1:58:BE:1A"])
def test_mac_already_in_subnet(fixtures, macaddr):
f = form(macaddr=macaddr)
assert not f.is_valid()
assert f.non_field_errors() == ["Macaddr already in this subnet"]
def test_mac_in_use_by_interface_without_ip4address(fixtures):
# Used to raise AttributeError on the other interface's missing address.
Interface.objects.update(ip4address=None)
f = form(macaddr="f0:de:f1:58:be:1a")
assert f.is_valid(), f.errors
def test_editing_interface_does_not_clash_with_itself(fixtures):
interface = Interface.objects.get()
f = form(
instance=interface,
name="eth0",
macaddr=interface.macaddr,
ip4address=interface.ip4address.pk,
)
assert f.is_valid(), f.errors
+142
View File
@@ -0,0 +1,142 @@
"""The dhcpd.conf and reverse zones generated from the test_data fixture."""
import pytest
from django.core.management import call_command
from freezegun import freeze_time
from mdb.models import DhcpConfig, Interface, Ip4Subnet, Ip6Subnet
from mdb.services.zone_sync import ZoneSyncService
EXPECTED_DHCPD_CONF = """\
# Autogenerated configuration 2024-01-01 00:00:00
default-lease-time 600;
max-lease-time 7200;
log-facility local7;
ddns-update-style none;
# clients
subnet 10.0.3.0 netmask 255.255.255.0 {
\toption domain-name "edb.technology";
\toption domain-name-servers 158.36.190.194, 158.36.190.199, 8.8.8.8;
\toption routers 158.36.186.1;
\tdeny unknown-clients;
\trange 10.0.3.2 10.0.3.254;
}
host tut {
\thardware ethernet f0:de:f1:58:be:1a;
\tfixed-address tut.edb.technology;
\tif substring(option vendor-class-identifier, 0, 20) = "PXEClient:Arch:00007" \
or substring(option vendor-class-identifier, 0, 20) = "PXEClient:Arch:00009" {
\t\tfilename "grubx64.efi";
\t} else {
\t\tfilename "pxelinux.0";
\t}
}
"""
SOA_AND_NS = """\
$TTL 3600
@ IN SOA sega.neuf.no. sysadmin.neuf.no. (
\t{serial}\t; serial
\t28800\t; refresh
\t7200\t; retry
\t604800\t; expire
\t86400 )\t; minimum ttl
;
;
@\tIN\tNS\tsega.neuf.no.
@\tIN\tNS\tatari.neuf.no.
;
"""
EXPECTED_IP4_REVERSE = (
SOA_AND_NS.format(serial=2024010101)
+ "2 \tIN\tPTR\ttut.edb.technology.\n"
)
EXPECTED_IP6_REVERSE = (
SOA_AND_NS.format(serial=1)
+ "2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0\tPTR\ttut.edb.technology.\n"
)
@pytest.fixture
def fixtures(db):
# Loading the interface bumps the subnet's serial to the frozen day's first.
with freeze_time("2024-01-01 00:00:00"):
call_command("loaddata", "test_data", verbosity=0)
@pytest.fixture
def service(tmp_path):
svc = ZoneSyncService()
svc.checkzone_dir = str(tmp_path)
return svc
def body(zone_file):
"""A zone file without its header comments, which name the file and the time."""
return zone_file[zone_file.index("$TTL") :]
@freeze_time("2024-01-01 00:00:00")
def test_dhcpd_configuration(fixtures):
assert DhcpConfig.objects.get().dhcpd_configuration() == EXPECTED_DHCPD_CONF
@freeze_time("2024-01-01 00:00:00")
def test_dhcpd_configuration_authoritative(fixtures):
DhcpConfig.objects.update(authoritative=True)
conf = DhcpConfig.objects.get().dhcpd_configuration()
assert conf.splitlines()[1] == "authoritative;"
def test_dhcpd_configuration_leaves_out_non_dhcp_hosts(fixtures):
Interface.objects.update(dhcp_client=False)
assert "host tut" not in DhcpConfig.objects.get().dhcpd_configuration()
def test_dhcpd_configuration_without_pxe_filename(fixtures):
Interface.objects.update(pxe_filename="")
conf = DhcpConfig.objects.get().dhcpd_configuration()
assert "host tut {" in conf
assert "filename" not in conf
def test_dhcpd_configuration_with_custom_pxe_filename(fixtures):
Interface.objects.update(pxe_filename="custom.efi")
conf = DhcpConfig.objects.get().dhcpd_configuration()
assert '\tfilename "custom.efi";\n' in conf
assert "vendor-class-identifier" not in conf
def test_dhcpd_configuration_without_dynamic_range(fixtures):
Ip4Subnet.objects.update(dhcp_dynamic=False)
assert "range" not in DhcpConfig.objects.get().dhcpd_configuration()
def test_ip4_reverse_zone(fixtures, service):
zone = Ip4Subnet.objects.get()
contents = zone.zone_file_contents()
assert body(contents) == EXPECTED_IP4_REVERSE
status, output = service.check_zone(zone, contents)
assert status == 0, output
def test_ip4_reverse_zone_leaves_out_unassigned_addresses(fixtures):
Interface.objects.update(ip4address=None)
assert "PTR" not in Ip4Subnet.objects.get().zone_file_contents()
def test_ip6_reverse_zone(fixtures, service):
zone = Ip6Subnet.objects.get()
contents = zone.zone_file_contents()
assert body(contents) == EXPECTED_IP6_REVERSE
status, output = service.check_zone(zone, contents)
assert status == 0, output
def test_ip6_reverse_zone_name():
zone = Ip6Subnet(network="2001:16d8:eeb6:3", netmask=64)
assert zone.reverse_zone_name == "3.0.0.0.6.b.e.e.8.d.6.1.1.0.0.2.ip6.arpa"
+37
View File
@@ -0,0 +1,37 @@
import pytest
from mdb.models import Ip4Subnet, Ip6Subnet
@pytest.mark.parametrize(
"network,netmask,expected",
[
# /24 and shorter -> standard octet-boundary name
("203.0.113.0", "255.255.255.0", "113.0.203.in-addr.arpa"),
("10.0.3.0", "255.255.255.0", "3.0.10.in-addr.arpa"),
("10.0.0.0", "255.255.0.0", "0.0.10.in-addr.arpa"),
# longer than /24 -> RFC 2317 classless name
("192.0.2.192", "255.255.255.192", "192-26.2.0.192.in-addr.arpa"),
("10.0.0.0", "255.255.255.252", "0-30.0.0.10.in-addr.arpa"),
("10.1.2.0", "255.255.255.128", "0-25.2.1.10.in-addr.arpa"),
# host bits set -> normalized down to the network address
("192.0.2.200", "255.255.255.192", "192-26.2.0.192.in-addr.arpa"),
],
)
def test_ip4_reverse_zone_name(network, netmask, expected):
subnet = Ip4Subnet(network=network, netmask=netmask)
assert subnet.reverse_zone_name == expected
@pytest.mark.parametrize(
"network,netmask,expected",
[
("2001:db8", 64, "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"),
("2001:db8", 48, "0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"),
("2001:db8:abcd", 56, "0.0.d.c.b.a.8.b.d.0.1.0.0.2.ip6.arpa"),
("2001:db8", 32, "8.b.d.0.1.0.0.2.ip6.arpa"),
],
)
def test_ip6_reverse_zone_name(network, netmask, expected):
subnet = Ip6Subnet(network=network, netmask=netmask)
assert subnet.reverse_zone_name == expected
+90
View File
@@ -0,0 +1,90 @@
import pytest
from django.contrib import messages
from django.core.management import call_command
from django.urls import reverse
from mdb.models import Host, Interface
@pytest.fixture
def db_with_fixtures(db):
call_command("loaddata", "test_data", verbosity=0)
@pytest.fixture
def host(db_with_fixtures):
return Host.objects.first()
def test_saving_installable_host_boots_it_from_the_network(host):
Interface.objects.update(pxe_filename="", dhcp_client=False)
host.save()
interface = host.interface_set.get()
assert interface.pxe_filename == "pxelinux.0"
assert interface.dhcp_client
def test_saving_host_keeps_custom_pxe_filename(host):
Interface.objects.update(pxe_filename="custom.efi")
host.save()
assert host.interface_set.get().pxe_filename == "custom.efi"
def test_saving_non_installable_host_stops_netboot(host):
Interface.objects.update(pxe_filename="pxelinux.0", dhcp_client=True)
host.pxe_installable = False
host.save()
interface = host.interface_set.get()
assert interface.pxe_filename == ""
# The host keeps its address.
assert interface.dhcp_client
def test_saving_non_installable_host_keeps_custom_pxe_filename(host):
Interface.objects.update(pxe_filename="custom.efi")
host.pxe_installable = False
host.save()
assert host.interface_set.get().pxe_filename == "custom.efi"
def test_saving_host_without_key_generates_one(host):
host.pxe_key = ""
host.save()
assert len(Host.objects.get(pk=host.pk).pxe_key) == 32
def test_admin_marks_hosts_installable(host, admin_client):
Host.objects.update(pxe_installable=False)
Interface.objects.update(pxe_filename="")
res = admin_client.post(
reverse("admin:mdb_host_changelist"),
{"action": "set_installable", "_selected_action": [host.pk]},
follow=True,
)
assert res.status_code == 200
assert Host.objects.get(pk=host.pk).pxe_installable
assert host.interface_set.get().pxe_filename == "pxelinux.0"
assert not [m for m in res.context["messages"] if m.level == messages.WARNING]
def test_admin_warns_about_interfaces_without_ip(host, admin_client):
Host.objects.update(pxe_installable=False)
Interface.objects.create(
name="eth1", macaddr="00:11:22:33:44:55", host=host, domain_id=1
)
res = admin_client.post(
reverse("admin:mdb_host_changelist"),
{"action": "set_installable", "_selected_action": [host.pk]},
follow=True,
)
warnings = [str(m) for m in res.context["messages"] if m.level == messages.WARNING]
assert warnings == [
"Host tut might not be installable since interface(s) eth1 has no IP set."
]
assert Host.objects.get(pk=host.pk).pxe_installable
+57
View File
@@ -0,0 +1,57 @@
"""How mdbsite.settings loads production's settings, in a fresh interpreter each time,
since the settings module is imported once per process."""
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PRINT = (
"import django, os; os.environ['DJANGO_SETTINGS_MODULE'] = 'mdbsite.settings'; "
"django.setup(); from django.conf import settings as s; print(s.ALLOWED_HOSTS, s.DEBUG)"
)
def run(env):
return subprocess.run(
[sys.executable, "-c", PRINT],
cwd=ROOT,
env={
k: v
for k, v in os.environ.items()
if k not in ("MDB_SETTINGS", "DJANGO_DEBUG")
}
| env,
capture_output=True,
text=True,
)
def test_settings_file_overrides(tmp_path):
f = tmp_path / "settings.py"
f.write_text("ALLOWED_HOSTS = ['mdb.example.org']\n")
result = run({"MDB_SETTINGS": str(f)})
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "['mdb.example.org'] False"
def test_failing_settings_file_stops_mdb(tmp_path):
# As on sega after the 24.04 upgrade: python-ldap would not load, and the old
# `except ImportError: pass` left MDB running on the defaults.
f = tmp_path / "settings.py"
f.write_text("import a_module_that_does_not_exist\n")
result = run({"MDB_SETTINGS": str(f)})
assert result.returncode != 0
assert "a_module_that_does_not_exist" in result.stderr
def test_missing_settings_file_stops_mdb(tmp_path):
result = run({"MDB_SETTINGS": str(tmp_path / "missing.py")})
assert result.returncode != 0
def test_debug_is_off_unless_asked():
assert run({}).stdout.strip().endswith("False")
assert run({"DJANGO_DEBUG": "1"}).stdout.strip().endswith("True")
+86
View File
@@ -0,0 +1,86 @@
import pytest
from freezegun import freeze_time
from mdb.models import DhcpConfig, Domain, Interface, Ip4Subnet, Ip6Subnet
# ---------------------------------------------------------------------------
# pre_save signals: populate domain_name once, then leave it frozen
# ---------------------------------------------------------------------------
def make_ip4subnet(**overrides):
dhcp_config = DhcpConfig.objects.create(serial=1, active_serial=1)
fields = {
"name": "test-subnet",
"netmask": "255.255.255.192",
"network": "192.0.2.192",
"domain_soa": "ns1.example.org",
"domain_admin": "admin@example.org",
"domain_filename": "db.test-subnet",
"dhcp_config": dhcp_config,
}
fields.update(overrides)
return Ip4Subnet.objects.create(**fields)
@pytest.mark.django_db
def test_ip4_signal_sets_classless_domain_name():
subnet = make_ip4subnet()
assert subnet.domain_name == "192-26.2.0.192.in-addr.arpa"
@pytest.mark.django_db
def test_ip4_signal_sets_plain_domain_name_for_slash24():
subnet = make_ip4subnet(network="203.0.113.0", netmask="255.255.255.0")
assert subnet.domain_name == "113.0.203.in-addr.arpa"
@pytest.mark.django_db
def test_ip4_signal_does_not_overwrite_existing_domain_name():
subnet = make_ip4subnet(domain_name="manual.in-addr.arpa")
assert subnet.domain_name == "manual.in-addr.arpa"
@pytest.mark.django_db
def test_ip4_domain_name_is_frozen_when_network_changes():
subnet = make_ip4subnet()
assert subnet.domain_name == "192-26.2.0.192.in-addr.arpa"
subnet.network = "10.0.0.0"
subnet.netmask = "255.255.255.0"
subnet.save()
subnet.refresh_from_db()
# The name is derived once and then left alone.
assert subnet.domain_name == "192-26.2.0.192.in-addr.arpa"
@pytest.mark.django_db
def test_ip6_signal_sets_domain_name():
subnet = Ip6Subnet.objects.create(
name="ipv6-doc",
network="2001:db8",
netmask=64,
domain_soa="ns1.example.org",
domain_admin="admin@example.org",
domain_filename="db.reverse-v6",
)
assert subnet.domain_name == "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"
# ---------------------------------------------------------------------------
# pre_delete Interface: the zones it was in get a new serial
# ---------------------------------------------------------------------------
@freeze_time("2026-11-02")
def test_deleting_interface_raises_zone_serials(domain):
subnet = Interface.objects.get(pk=1).ip4address.subnet
Domain.objects.filter(pk=domain.pk).update(domain_serial=2026110299)
Ip4Subnet.objects.filter(pk=subnet.pk).update(domain_serial=2015010101)
Interface.objects.get(pk=1).delete()
# Past 99 the serial carries into the date instead of going back.
assert Domain.objects.get(pk=domain.pk).domain_serial == 2026110300
assert Ip4Subnet.objects.get(pk=subnet.pk).domain_serial == 2026110201
+32
View File
@@ -0,0 +1,32 @@
import pytest
from freezegun import freeze_time
from mdb.utils import format_domain_serial_and_add_one
@freeze_time("2026-11-02")
@pytest.mark.parametrize(
("serial", "expected"),
[
(2026110205, 2026110206), # today: next number
(2025010107, 2026110201), # an earlier day: today's first
(1, 2026110201), # not a date at all
(2026110298, 2026110299),
(2026110299, 2026110300), # used to stay at 99, so the change never synced
(2026110300, 2026110301), # ahead of today: never goes back
(2026110505, 2026110506),
(None, 2026110201),
("garbage", 2026110201),
],
)
def test_next_serial(serial, expected):
assert format_domain_serial_and_add_one(serial) == expected
@freeze_time("2026-11-02")
def test_serial_always_increases():
serial = 2026110290
for _ in range(30):
new = format_domain_serial_and_add_one(serial)
assert new > serial
serial = new
+373
View File
@@ -0,0 +1,373 @@
import os
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from freezegun import freeze_time
from mdb.models import (
DhcpConfig,
DomainARecord,
Host,
HostType,
Interface,
Ip4Subnet,
OperatingSystem,
)
from mdb.services.zone_sync import ZoneSyncService
EXPECTED_ZONE_FILE_CONTENTS = """\
; serial:2024010101
; zone file for example.org
; 2024-01-01 00:00:00
; filename: {filename}
$TTL 3600
@ IN SOA ns1.example.org. admin.example.org. (
\t2024010101\t; serial
\t28800\t; refresh
\t7200\t; retry
\t604800\t; expire
\t86400 )\t; minimum ttl
;
@\tIN\tNS\tns1.example.org.
@\tIN\tNS\tns2.example.org.
@\t\tMX\t10 mail.example.org.
@\tIN\tA\t1.2.3.4
@\tIN\tAAAA\t2001:db8::1
; SRV records
; A records
ns1 IN A 1.2.3.4
ns2 IN A 5.6.7.8
mail IN A 9.10.11.12
www IN A 1.2.3.4
; AAAA records
www IN AAAA 2001:db8::1
; CNAME records \nftp\tIN\tCNAME\twww.example.org.
; TXT records \n@ TXT "v=spf1 mx ~all"
; HOST records
"""
# named-compilezone output from BIND 9.18, which the DNS server runs.
# BIND 9.20 writes fully qualified names instead of relative ones.
EXPECTED_NORMALIZED = (
"$ORIGIN .\n"
"$TTL 3600\t; 1 hour\n"
"example.org\t\tIN SOA\tns1.example.org. admin.example.org. (\n"
"\t\t\t\t2024010101 ; serial\n"
"\t\t\t\t28800 ; refresh (8 hours)\n"
"\t\t\t\t7200 ; retry (2 hours)\n"
"\t\t\t\t604800 ; expire (1 week)\n"
"\t\t\t\t86400 ; minimum (1 day)\n"
"\t\t\t\t)\n"
"\t\t\tNS\tns1.example.org.\n"
"\t\t\tNS\tns2.example.org.\n"
"\t\t\tA\t1.2.3.4\n"
"\t\t\tMX\t10 mail.example.org.\n"
'\t\t\tTXT\t"v=spf1 mx ~all"\n'
"\t\t\tAAAA\t2001:db8::1\n"
"$ORIGIN example.org.\n"
"ftp\t\t\tCNAME\twww\n"
"mail\t\t\tA\t9.10.11.12\n"
"ns1\t\t\tA\t1.2.3.4\n"
"ns2\t\t\tA\t5.6.7.8\n"
"www\t\t\tA\t1.2.3.4\n"
"\t\t\tAAAA\t2001:db8::1"
)
SAMPLE_ZONE = """\
$TTL 3600
example.com. IN SOA ns1.example.com. admin.example.com. (
2024010101 ; serial
3600 ; refresh
900 ; retry
604800 ; expire
86400 ; minimum
)
example.com. IN NS ns1.example.com.
example.com. IN NS ns2.example.com.
example.com. IN MX 10 mail.example.com.
example.com. IN MX 20 mail2.example.com.
example.com. IN A 1.2.3.4
example.com. IN AAAA 2001:db8::1
example.com. IN TXT "v=spf1 mx ~all"
ns1.example.com. IN A 1.2.3.4
ns2.example.com. IN A 5.6.7.8
mail.example.com. IN A 9.10.11.12
www.example.com. IN CNAME example.com.
_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
"""
def make_zone(domain_name="example.com", active_serial=2024010101, serial=2024010102):
zone = MagicMock()
zone.domain_name = domain_name
zone.domain_active_serial = active_serial
zone.domain_serial = serial
return zone
def test_calculate_diff_identical_returns_empty(service):
zone = make_zone()
assert service.calculate_diff("line\n", "line\n", zone) == ""
def test_calculate_diff_format(service):
zone = make_zone(domain_name="example.com", active_serial=1, serial=2)
diff = service.calculate_diff("new line\n", "old line\n", zone)
assert "*** 1-example.com" in diff
assert "--- 2-example.com" in diff
assert "! old line" in diff
assert "! new line" in diff
def test_normalize_zone_is_idempotent(service):
zone = make_zone()
normalized = service.normalize_zone(zone, SAMPLE_ZONE)
assert service.normalize_zone(zone, normalized) == normalized
def test_normalize_zone_fallback_when_binary_missing(service):
service.compilezone_bin = "/nonexistent/named-compilezone"
zone = make_zone()
assert service.normalize_zone(zone, SAMPLE_ZONE) == SAMPLE_ZONE
def test_check_zone_valid(service):
zone = make_zone()
status, _ = service.check_zone(zone, SAMPLE_ZONE)
assert status == 0
def test_check_zone_invalid(service):
zone = make_zone()
status, _ = service.check_zone(zone, "this is not a valid zone file")
assert status != 0
@freeze_time("2024-01-01 00:00:00")
def test_zone_file_contents_includes_all_record_types(db_domain):
content = db_domain.zone_file_contents()
assert content == EXPECTED_ZONE_FILE_CONTENTS.format(
filename=db_domain.domain_filename
)
def bind_version():
try:
out = subprocess.run(
[ZoneSyncService.compilezone_bin, "-v"], capture_output=True, text=True
).stdout
except OSError:
return None
return out.strip()
@pytest.mark.skipif(
not (bind_version() or "").startswith("9.18."),
reason="expected output is from BIND 9.18, which the DNS server runs",
)
def test_zone_file_contents_normalizes_without_error(db_domain, service):
normalized = service.normalize_zone(db_domain, db_domain.zone_file_contents())
assert normalized == EXPECTED_NORMALIZED
def test_check_zone_accepts_zone_file_contents(db_domain, service):
content = service.normalize_zone(db_domain, db_domain.zone_file_contents())
status, output = service.check_zone(db_domain, content)
assert status == 0, output
assert "loaded serial 2024010101" in output
assert output.endswith("OK")
def test_read_serial_from_normalized_zone():
assert ZoneSyncService().read_serial(EXPECTED_NORMALIZED) == 2024010101
def test_read_serial_from_generated_zone():
assert ZoneSyncService().read_serial(SAMPLE_ZONE) == 2024010101
def test_read_serial_none_without_soa():
assert ZoneSyncService().read_serial("") is None
def test_pending_zones_skips_synced_zones(db_domain, service):
assert service.pending_zones() == [db_domain]
db_domain.domain_active_serial = db_domain.domain_serial
db_domain.save()
assert service.pending_zones() == []
service.force = True
assert service.pending_zones() == [db_domain]
def test_find_zone_returns_matching_domain(db_domain, service):
assert service.find_zone("example.org") == db_domain
@pytest.mark.django_db
def test_find_zone_returns_none_for_unknown(service):
assert service.find_zone("nonexistent.example") is None
def test_sync_zone_writes_zone_file_and_updates_serial(db_domain, tmp_path):
svc = ZoneSyncService(debug=False)
svc.checkzone_dir = str(tmp_path)
svc.sync_zone(db_domain)
assert os.path.exists(db_domain.domain_filename)
db_domain.refresh_from_db()
assert db_domain.domain_active_serial == db_domain.domain_serial
def test_sync_zone_records_success_and_diff(db_domain, tmp_path):
svc = ZoneSyncService(debug=False)
svc.checkzone_dir = str(tmp_path)
svc.sync_zone(db_domain)
change = svc.changes["example.org"]
assert change["success"] is True
assert change["errors"] is None
assert change["old_serial"] == 0
assert change["new_serial"] == 2024010101
def test_sync_zone_raises_serial_past_live_zone(db_domain, tmp_path):
"""Dynamic updates can leave the live serial above the one mdb generated."""
with open(db_domain.domain_filename, "w") as f:
f.write(EXPECTED_NORMALIZED.replace("2024010101", "2024010137"))
svc = ZoneSyncService(debug=False)
svc.checkzone_dir = str(tmp_path)
svc.sync_zone(db_domain)
db_domain.refresh_from_db()
assert db_domain.domain_serial == 2024010138
assert db_domain.domain_active_serial == 2024010138
assert svc.changes["example.org"]["new_serial"] == 2024010138
with open(db_domain.domain_filename) as f:
assert svc.read_serial(f.read()) == 2024010138
def test_sync_zone_keeps_serial_above_live_zone(db_domain, tmp_path):
with open(db_domain.domain_filename, "w") as f:
f.write(EXPECTED_NORMALIZED.replace("2024010101", "2024010099"))
svc = ZoneSyncService(debug=False)
svc.checkzone_dir = str(tmp_path)
svc.sync_zone(db_domain)
db_domain.refresh_from_db()
assert db_domain.domain_serial == 2024010101
def add_host(domain, hostname, allow_email, ip4address=None):
host_type = HostType.objects.create(host_type="server", description="")
operating_system = OperatingSystem.objects.create(
name="Debian", version="12", arch="x86_64"
)
host = Host.objects.create(
hostname=hostname,
host_type=host_type,
operating_system=operating_system,
allow_email=allow_email,
)
Interface.objects.create(
name="eth0",
macaddr="00:11:22:33:44:55",
host=host,
domain=domain,
ip4address=ip4address,
)
# Creating the interface bumps the domain serial via a signal; reload so the
# in-memory domain stays consistent with the DB.
domain.refresh_from_db()
return host
def make_ip4address():
"""Create a tiny (/30) subnet; its post_save signal generates the host IPs."""
dhcp_config = DhcpConfig.objects.create(serial=1, active_serial=1)
subnet = Ip4Subnet.objects.create(
name="test-subnet",
netmask="255.255.255.252",
network="10.0.0.0",
domain_soa="ns1.example.org",
domain_admin="admin@example.org",
domain_filename="db.test-subnet",
dhcp_config=dhcp_config,
)
return subnet.ip4address_set.get(address="10.0.0.1")
def test_zone_file_contents_includes_spf_record_when_email_allowed(db_domain):
add_host(db_domain, "mailhost", allow_email=True)
content = db_domain.zone_file_contents()
assert 'mailhost\tTXT\t"v=spf1 mx -all"\n' in content
def test_zone_file_contents_omits_spf_record_when_email_not_allowed(db_domain):
add_host(db_domain, "plainhost", allow_email=False)
content = db_domain.zone_file_contents()
assert "v=spf1 mx -all" not in content
def test_zone_file_contents_includes_ip4_in_spf_record_when_interface_has_address(
db_domain,
):
address = make_ip4address()
add_host(db_domain, "mailhost", allow_email=True, ip4address=address)
content = db_domain.zone_file_contents()
assert f'mailhost\tTXT\t"v=spf1 mx ip4:{address.address} -all"\n' in content
def test_sync_zone_does_not_write_a_zone_that_fails_validation(db_domain, tmp_path):
with open(db_domain.domain_filename, "w") as f:
f.write("the live zone\n")
DomainARecord.objects.create(domain=db_domain, name="bad", target="not-an-address")
svc = ZoneSyncService(debug=False)
svc.checkzone_dir = str(tmp_path)
assert svc.sync_zone(db_domain) is False
with open(db_domain.domain_filename) as f:
assert f.read() == "the live zone\n"
db_domain.refresh_from_db()
assert db_domain.domain_active_serial == 0
change = svc.changes["example.org"]
assert change["success"] is False
assert "not-an-address" in change["errors"]
def test_write_zone_file_replaces_file_and_keeps_its_mode(service, tmp_path):
path = tmp_path / "db.example.org"
path.write_text("old\n")
path.chmod(0o664)
inode = path.stat().st_ino
service.write_zone_file(str(path), "new\n")
assert path.read_text() == "new\n"
assert path.stat().st_mode & 0o777 == 0o664
assert path.stat().st_ino != inode # renamed over, not written in place
assert os.listdir(tmp_path) == ["db.example.org"]
def test_write_zone_file_creates_missing_file(service, tmp_path):
path = tmp_path / "db.example.org"
service.write_zone_file(str(path), "new\n")
assert path.read_text() == "new\n"
assert path.stat().st_mode & 0o777 == 0o644
def test_write_zone_file_leaves_old_file_when_write_fails(service, tmp_path):
path = tmp_path / "db.example.org"
path.write_text("old\n")
with patch("os.fsync", side_effect=OSError("disk full")), pytest.raises(OSError):
service.write_zone_file(str(path), "new\n")
assert path.read_text() == "old\n"
assert os.listdir(tmp_path) == ["db.example.org"]
+369
View File
@@ -0,0 +1,369 @@
import os
import signal
from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
from mdb.management.commands.zone_sync import Command as CommandClass
from mdb.services.zone_sync import ZoneSyncService
@pytest.fixture(autouse=True)
def checkzone_dir(tmp_path, monkeypatch):
"""Where the frozen marker lives; not the real /tmp/mdb-checkzone."""
monkeypatch.setattr(ZoneSyncService, "checkzone_dir", str(tmp_path))
return tmp_path
@pytest.fixture
def cmd():
c = CommandClass()
c.stdout = StringIO()
c.stderr = StringIO()
return c
def make_service(changes=None):
svc = MagicMock()
svc.changes = changes or {
"example.org": {"errors": None, "diff": "- old\n+ new", "success": True}
}
svc.reload.return_value = (True, "", "")
return svc
# --- run_commands, freeze, reload ---
def test_run_commands_calls_all_on_success(service):
with patch("mdb.services.zone_sync.getstatusoutput", return_value=(0, "")) as mock:
success, _, _ = service.run_commands(["a", "b", "c"])
assert success is True
assert mock.call_count == 3
def test_run_commands_stops_on_first_failure(service):
with patch(
"mdb.services.zone_sync.getstatusoutput",
side_effect=[(0, ""), (1, "connection refused"), (0, "")],
) as mock:
success, failed_cmd, output = service.run_commands(["a", "b", "c"])
assert success is False
assert failed_cmd == "b"
assert output == "connection refused"
assert mock.call_count == 2
def test_freeze_runs_only_freeze(service):
with patch("mdb.services.zone_sync.getstatusoutput", return_value=(0, "")) as mock:
service.freeze()
assert [c.args[0] for c in mock.call_args_list] == [service.freeze_command]
def test_reload_runs_reload_then_thaw(service):
with patch("mdb.services.zone_sync.getstatusoutput", return_value=(0, "")) as mock:
service.reload()
assert [c.args[0] for c in mock.call_args_list] == [
service.reload_command,
service.thaw_command,
]
# --- reload_and_notify ---
def test_reload_and_notify_success_writes_ok(cmd):
svc = make_service()
with patch("mdb.management.commands.zone_sync.discord"):
cmd.reload_and_notify(svc, svc.changes)
assert "ok" in cmd.stdout.getvalue()
def test_reload_and_notify_success_calls_discord_once_per_zone(cmd):
changes = {
"example.org": {"errors": None, "diff": "- old\n+ new"},
"other.net": {"errors": None, "diff": "- x\n+ y"},
}
svc = make_service(changes=changes)
with patch("mdb.management.commands.zone_sync.discord") as mock_discord:
cmd.reload_and_notify(svc, svc.changes)
assert mock_discord.notify.call_count == 2
subjects = [c.kwargs["subject"] for c in mock_discord.notify.call_args_list]
messages = [c.kwargs["message"] for c in mock_discord.notify.call_args_list]
assert all("✅" in s for s in subjects)
assert any("`example.org`" in m for m in messages)
assert any("`other.net`" in m for m in messages)
def test_reload_and_notify_success_passes_diff_as_preformatted(cmd):
svc = make_service()
with patch("mdb.management.commands.zone_sync.discord") as mock_discord:
cmd.reload_and_notify(svc, svc.changes)
assert mock_discord.notify.call_args.kwargs["preformatted"] == "- old\n+ new"
def test_reload_and_notify_failure_writes_fail_and_exits(cmd):
svc = make_service()
svc.reload.return_value = (False, "/usr/sbin/rndc reload", "timed out")
with patch("mdb.management.commands.zone_sync.discord"):
with patch("getpass.getuser", return_value="testuser"):
with pytest.raises(SystemExit) as exc:
cmd.reload_and_notify(svc, svc.changes)
assert exc.value.code == 1
assert "fail" in cmd.stdout.getvalue()
def test_reload_and_notify_failure_discord_includes_command_and_output(cmd):
svc = make_service()
svc.reload.return_value = (False, "/usr/sbin/rndc reload", "timed out")
with patch("mdb.management.commands.zone_sync.discord") as mock_discord:
with patch("getpass.getuser", return_value="testuser"):
with pytest.raises(SystemExit):
cmd.reload_and_notify(svc, svc.changes)
subject = mock_discord.notify.call_args.kwargs["subject"]
message = mock_discord.notify.call_args.kwargs["message"]
assert "⚠️" in subject
assert "rndc reload" in message
assert "timed out" in message
assert "- example.org" in message
# --- handle ---
@pytest.mark.django_db
def test_handle_exits_if_zone_not_found(cmd):
with pytest.raises(SystemExit) as exc:
cmd.handle(debug=True, force=False, zone="nonexistent.example")
assert exc.value.code == 1
assert "nonexistent.example" in cmd.stderr.getvalue()
@pytest.mark.django_db
def test_handle_skips_reload_in_debug_mode(cmd, db_domain):
with patch.object(cmd, "reload_and_notify") as mock_reload:
cmd.handle(debug=True, force=True, zone="example.org")
mock_reload.assert_not_called()
@pytest.mark.django_db
def test_handle_calls_reload_and_notify_when_changes_exist(cmd, db_domain):
with (
patch.object(cmd, "freeze"),
patch.object(cmd, "reload_and_notify") as mock_reload,
):
cmd.handle(debug=False, force=True, zone="example.org")
mock_reload.assert_called_once()
@pytest.mark.django_db
def test_handle_does_nothing_without_pending_zones(cmd):
with patch.object(ZoneSyncService, "freeze") as mock_freeze:
cmd.handle(debug=False, force=False, zone=None)
mock_freeze.assert_not_called()
@pytest.mark.django_db
def test_handle_freezes_before_writing_and_reloads_after(cmd, db_domain):
"""rndc freeze dumps the in-memory zone, so it must not run after the write."""
seen = []
def freeze():
seen.append(("freeze", os.path.exists(db_domain.domain_filename)))
return True, "", ""
def reload():
seen.append(("reload", os.path.exists(db_domain.domain_filename)))
return True, "", ""
with (
patch.object(ZoneSyncService, "freeze", side_effect=freeze),
patch.object(ZoneSyncService, "reload", side_effect=reload),
patch("mdb.management.commands.zone_sync.discord"),
):
cmd.handle(debug=False, force=False, zone=None)
assert seen == [("freeze", False), ("reload", True)]
@pytest.mark.django_db
def test_handle_writes_nothing_when_freeze_fails(cmd, db_domain):
with (
patch.object(
ZoneSyncService, "freeze", return_value=(False, "rndc freeze", "no")
),
patch.object(ZoneSyncService, "thaw", return_value=(True, "", "")) as thaw,
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
patch("getpass.getuser", return_value="testuser"),
pytest.raises(SystemExit),
):
cmd.handle(debug=False, force=False, zone=None)
assert not os.path.exists(db_domain.domain_filename)
thaw.assert_called_once()
assert "- example.org" in mock_discord.notify.call_args.kwargs["message"]
@pytest.mark.django_db
def test_handle_thaws_when_sync_raises(cmd, db_domain):
with (
patch.object(ZoneSyncService, "freeze", return_value=(True, "", "")),
patch.object(
ZoneSyncService, "reload", return_value=(True, "", "")
) as mock_reload,
patch.object(ZoneSyncService, "sync_zone", side_effect=RuntimeError),
patch("mdb.management.commands.zone_sync.discord"),
pytest.raises(RuntimeError),
):
cmd.handle(debug=False, force=False, zone=None)
mock_reload.assert_called_once()
# --- stopped and killed syncs ---
def rndc(results=None):
"""Patch rndc: every command succeeds unless results maps it to (status, output)."""
results = results or {}
calls = []
def run(command):
calls.append(command.rsplit(" ", 1)[-1])
return results.get(command, (0, ""))
return calls, patch("mdb.services.zone_sync.getstatusoutput", side_effect=run)
def test_reload_thaws_even_when_reload_fails(service):
calls, rndc_patch = rndc({ZoneSyncService.reload_command: (1, "timed out")})
with rndc_patch:
success, failed_command, output = service.reload()
assert calls == ["reload", "thaw"]
assert (success, failed_command, output) == (
False,
ZoneSyncService.reload_command,
"timed out",
)
def test_marker_exists_only_while_frozen(service):
calls, rndc_patch = rndc()
with rndc_patch:
service.freeze()
assert service.frozen_since() is not None
service.reload()
assert service.frozen_since() is None
def test_marker_stays_when_thaw_fails(service):
calls, rndc_patch = rndc({ZoneSyncService.thaw_command: (1, "no")})
with rndc_patch:
service.freeze()
service.reload()
assert service.frozen_since() is not None
@pytest.mark.django_db
@pytest.mark.parametrize("signum", [signal.SIGHUP, signal.SIGTERM, signal.SIGINT])
def test_handle_thaws_and_warns_when_stopped_by_signal(cmd, db_domain, signum):
def killed(zone):
os.kill(os.getpid(), signum)
calls, rndc_patch = rndc()
previous = signal.getsignal(signum)
with (
rndc_patch,
patch.object(ZoneSyncService, "sync_zone", side_effect=killed),
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
pytest.raises(SystemExit) as exc,
):
cmd.handle(debug=False, force=False, zone=None)
assert exc.value.code == 128 + signum
assert calls == ["freeze", "reload", "thaw"]
assert ZoneSyncService().frozen_since() is None
kwargs = mock_discord.notify.call_args.kwargs
assert kwargs["subject"] == "⚠️ Zone sync interrupted, zones thawed"
assert signal.Signals(signum).name in kwargs["message"]
assert "Not written, run mdb-sync again:\n- example.org" in kwargs["message"]
assert signal.getsignal(signum) == previous
@pytest.mark.django_db
def test_handle_ignores_signals_while_rndc_runs(cmd, db_domain):
seen = []
def run(commands):
seen.append((commands, signal.getsignal(signal.SIGHUP)))
return True, "", ""
with (
patch.object(ZoneSyncService, "run_commands", side_effect=run),
patch("mdb.management.commands.zone_sync.discord"),
):
cmd.handle(debug=False, force=False, zone=None)
# Signals still stop named-checkzone and the writes, between freeze and reload.
assert seen == [
([ZoneSyncService.freeze_command], signal.SIG_IGN),
([ZoneSyncService.reload_command], signal.SIG_IGN),
([ZoneSyncService.thaw_command], signal.SIG_IGN),
]
@pytest.mark.django_db
def test_handle_warns_when_reload_after_signal_fails(cmd, db_domain):
calls, rndc_patch = rndc({ZoneSyncService.thaw_command: (1, "no")})
with (
rndc_patch,
patch.object(
ZoneSyncService,
"sync_zone",
side_effect=lambda zone: os.kill(os.getpid(), signal.SIGHUP),
),
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
patch("getpass.getuser", return_value="testuser"),
pytest.raises(SystemExit),
):
cmd.handle(debug=False, force=False, zone=None)
kwargs = mock_discord.notify.call_args.kwargs
assert kwargs["subject"] == "⚠️ FAIL: bind"
assert "may still be frozen" in kwargs["message"]
assert ZoneSyncService().frozen_since() is not None
@pytest.mark.django_db
def test_handle_thaws_zones_a_killed_sync_left_frozen(cmd, checkzone_dir):
(checkzone_dir / "zones-frozen").write_text("1234\n")
calls, rndc_patch = rndc()
with (
rndc_patch,
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
):
cmd.handle(debug=False, force=False, zone=None)
assert calls == ["reload", "thaw"]
assert ZoneSyncService().frozen_since() is None
kwargs = mock_discord.notify.call_args.kwargs
assert kwargs["subject"] == "⚠️ Zones thawed after a killed zone sync"
assert "left every zone frozen" in kwargs["message"]
@pytest.mark.django_db
def test_handle_stops_when_thawing_a_killed_sync_fails(cmd, db_domain, checkzone_dir):
(checkzone_dir / "zones-frozen").write_text("1234\n")
calls, rndc_patch = rndc({ZoneSyncService.reload_command: (1, "no")})
with (
rndc_patch,
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
patch("getpass.getuser", return_value="testuser"),
pytest.raises(SystemExit),
):
cmd.handle(debug=False, force=False, zone=None)
assert calls == ["reload", "thaw"] # nothing frozen again, nothing written
assert not os.path.exists(db_domain.domain_filename)
assert "thawing them failed" in mock_discord.notify.call_args.kwargs["message"]
Generated
+415
View File
@@ -0,0 +1,415 @@
version = 1
revision = 5
requires-python = "==3.14.*"
[[package]]
name = "asgiref"
version = "3.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/90/f5fce14e95fccf53b58e8e2c47cd84d40d105d64073ae1ac75166db150e0/asgiref-3.13.0.tar.gz", hash = "sha256:034cc8cbf72d00553b4be256703ac545120b4e50af13b49dec99da899aeeecb4", size = 52941, upload-time = "2026-10-23T08:13:14.715Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/b0/ec211e1f7966247cb6469158d856bac9aa4c2fe13621bd6a352e4f850453/asgiref-3.13.0-py3-none-any.whl", hash = "sha256:f9596a2434512686473d50f96ff6207fbba49ae1a785dfac614df4aeb085674d", size = 27593, upload-time = "2026-10-23T08:13:13.394Z" },
]
[[package]]
name = "certifi"
version = "2026.10.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" },
{ url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" },
{ url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" },
{ url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" },
{ url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" },
{ url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" },
{ url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" },
{ url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" },
{ url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" },
{ url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" },
{ url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" },
{ url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" },
{ url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" },
{ url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" },
{ url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" },
{ url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" },
{ url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" },
{ url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" },
{ url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" },
{ url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" },
{ url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" },
{ url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" },
{ url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" },
{ url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" },
{ url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" },
{ url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" },
{ url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" },
{ url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" },
{ url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" },
{ url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" },
{ url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
{ url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
{ url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
{ url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
{ url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
{ url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
{ url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "django"
version = "6.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/24/20d187a22ac821288b53c53b8f696019dcb0c7b4f973f052ade19392c74d/django-6.1.2.tar.gz", hash = "sha256:a1e92451ccb8b514e91bbb3b6d186d20b4030558f116b5d9de6535455ff210b7", size = 11251425, upload-time = "2026-10-06T12:53:29.622Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/83/90ff2dbfac7b519ed77620bdbb827cd2d1a83faf00d076bf95056428bce7/django-6.1.2-py3-none-any.whl", hash = "sha256:141efee6ec64d1db6db90683bf734c550102450f444fb099063b0be1bd27d991", size = 8420684, upload-time = "2026-10-06T12:53:17.381Z" },
]
[[package]]
name = "django-auth-ldap"
version = "5.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "python-ldap" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/3a/ff3c0ac9cd315a4b872b0c913463643ce2df1306b10db1e054136edf579a/django_auth_ldap-5.4.0.tar.gz", hash = "sha256:66740dbfba0f41ea6c030bc53974787cbc2f27ed1f3796ebf9fe0cbc9917605e", size = 56416, upload-time = "2026-10-20T21:29:07.937Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/95/93bf708f2cba96e5c2ab6aabc3694c7c3dbdd25df1461af2e16a09d1bce8/django_auth_ldap-5.4.0-py3-none-any.whl", hash = "sha256:8a8e38add49be404e885e94216ff8e7611e11e55bf6cb6576c3b560a6a25dc20", size = 20893, upload-time = "2026-10-20T21:29:06.383Z" },
]
[[package]]
name = "django-extensions"
version = "4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
]
[[package]]
name = "djangorestframework"
version = "3.18.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/b2/1cee76b188301f7d03169ca77fab2b3d3ec5d01b3469189f944af2d7f11a/djangorestframework-3.18.3.tar.gz", hash = "sha256:446a9b352e7eff630421ab3f2328bd2401b109a9470afa4a31189994911ed030", size = 923563, upload-time = "2026-10-06T21:33:51.521Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/dd/de5f4543765bec94e036a5e9593a42f8fa7efec8de78eb56b9ca834385f3/djangorestframework-3.18.3-py3-none-any.whl", hash = "sha256:8544bb674846731b1e3c9b309236ee1dc412905a0aa725be2ec193ca950a7d12", size = 903986, upload-time = "2026-10-06T21:33:49.917Z" },
]
[[package]]
name = "freezegun"
version = "1.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
]
[[package]]
name = "gunicorn"
version = "26.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "mdb"
version = "0.1.0"
source = { editable = "." }
default-groups = ["dev", "prod"]
dependencies = [
{ name = "django" },
{ name = "django-extensions" },
{ name = "djangorestframework" },
{ name = "gunicorn" },
{ name = "requests" },
{ name = "sentry-sdk" },
{ name = "whitenoise" },
]
[package.dev-dependencies]
dev = [
{ name = "freezegun" },
{ name = "pytest" },
{ name = "pytest-django" },
{ name = "ruff" },
]
prod = [
{ name = "django-auth-ldap" },
]
[package.metadata]
requires-dist = [
{ name = "django", specifier = ">=6.1.2" },
{ name = "django-extensions", specifier = ">=4.1" },
{ name = "djangorestframework", specifier = ">=3.18.3" },
{ name = "gunicorn", specifier = ">=26.2.0" },
{ name = "requests", specifier = ">=2.34.2" },
{ name = "sentry-sdk", specifier = ">=2.73.0" },
{ name = "whitenoise", specifier = ">=6.12,<7" },
]
[package.metadata.requires-dev]
dev = [
{ name = "freezegun", specifier = ">=1.5.5" },
{ name = "pytest", specifier = ">=9.2.0" },
{ name = "pytest-django", specifier = ">=4.14.0" },
{ name = "ruff", specifier = ">=0.17.3,<1.0.0" },
]
prod = [{ name = "django-auth-ldap", specifier = ">=5.4.0" }]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/7e/3c0da41061a2021cac7cd524fd57092adbe5b515e8bf9005702e366802e2/pyasn1-0.6.5.tar.gz", hash = "sha256:6b11616a6bcf3a40e635f082bb232fa57b5e0dd7f517bd7ddd69ec3794508a47", size = 155152, upload-time = "2026-10-27T06:32:49.125Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/1f/3eefd5c84c35cae44549ce1c7f7e58f7945b34f5a6bce07a135438522073/pyasn1-0.6.5-py3-none-any.whl", hash = "sha256:4017b0fce125096b8573b39c989fdb1a4b19cf761e683398184550fea0e66587", size = 84807, upload-time = "2026-10-27T06:32:48.045Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
]
[[package]]
name = "pytest-django"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" },
]
[[package]]
name = "python-dateutil"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/39/b3927f3616f6223832f9b760e4208e8a7c7e1dea2b4d9ad7d32a5d214c3a/python_dateutil-4.0.0.tar.gz", hash = "sha256:d7295ff14fdc0f92f662100bfb6c551d160ca090f2939f1ec5b9794265c03f06", size = 250419, upload-time = "2026-11-01T07:02:42.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/a9/52b89563d154433d79b51ed1eb4842bf7334cb2696891a7fe135618853d7/python_dateutil-4.0.0-py3-none-any.whl", hash = "sha256:a5e246f79eb80475e691bf93862cead98decd8a498cf53ba0516fcdc0f50dc1c", size = 92853, upload-time = "2026-11-01T07:02:40.797Z" },
]
[[package]]
name = "python-ldap"
version = "3.4.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/45/489fcf46984f916ef165cdc6a2a1f1ca01ab6f486d47169256ebe44bdd62/python_ldap-3.4.8.tar.gz", hash = "sha256:18dc7460470c6ff64ed5c04ee21c56dbfee7ab433a53213ba91e407eea44c34c", size = 387710, upload-time = "2026-09-16T12:41:20.15Z" }
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "ruff"
version = "0.17.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.73.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/3b/1e47acf2456781c26e12007379ecc154bf310b8f64d40390d78b791d55c3/sentry_sdk-2.73.0.tar.gz", hash = "sha256:4a2ab97b16e4693c325ab361c7bbbb346dbc7d0bc0150922d795a25bac8d5280", size = 1079078, upload-time = "2026-10-22T09:08:49.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/88/cabf98d017d7b940720e514e5f8ee02e3e6f7495caf93c30735a38387ec4/sentry_sdk-2.73.0-py3-none-any.whl", hash = "sha256:0bcfa6928ea5adbb89591858b514d27adac27c7a3cccc31b20ed10e0b09eda9e", size = 549011, upload-time = "2026-10-22T09:08:47.88Z" },
]
[[package]]
name = "sqlparse"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/e0/361c764ea58cc1a75f7b4b424a74b08449db8bfa5e9199c7275999fdd1b9/sqlparse-0.6.1.tar.gz", hash = "sha256:ca722573f848ff2d30e40ea30f53a104b7dea7635ea9285891263eb2c58fb6e7", size = 182236, upload-time = "2026-11-01T18:06:45.951Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/ff/2fee788e82e75be8f210c65c5d103bf44c880e3769c884652a4cd911b8ba/sqlparse-0.6.1-py3-none-any.whl", hash = "sha256:3be5a72d04c873d690d7c6dedd33d6fae2ef82759881ceec02fc2efd0cba564f", size = 51200, upload-time = "2026-11-01T18:06:47.434Z" },
]
[[package]]
name = "tzdata"
version = "2026.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz", hash = "sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d", size = 201017, upload-time = "2026-10-28T09:57:09.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl", hash = "sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec", size = 348210, upload-time = "2026-10-28T09:57:07.276Z" },
]
[[package]]
name = "urllib3"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]]
name = "whitenoise"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
]