Compare commits

2 Commits
Author SHA1 Message Date
Nikolai R Kristiansen 9dc195fd46 python2 cleanups 2018-08-12 23:30:40 +02:00
Nikolai R Kristiansen d5d63b4dca Try bumping to django 2.0 2018-08-12 23:26:12 +02:00
80 changed files with 7810 additions and 3780 deletions

No files matched your search

-61
View File
@@ -1,61 +0,0 @@
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
@@ -1,10 +0,0 @@
# 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`
+20
View File
@@ -0,0 +1,20 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
django = "==2.0.8"
django-auth-ldap = "==1.7.0"
django-extensions = "==2.1.0"
djangorestframework = "==3.8.2"
gunicorn = "==19.9.0"
ipaddress = "==1.0.22"
mysqlclient = "==1.3.13"
raven = "==6.9.0"
django-suit = "*"
[dev-packages]
[requires]
python_version = "3.6"
Generated
+147
View File
@@ -0,0 +1,147 @@
{
"_meta": {
"hash": {
"sha256": "2280ffc42c9d702c0cfeba59fdb86215b1a8cad743ae3dc49fd6790aa00cf885"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.6"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"django": {
"hashes": [
"sha256:0c5b65847d00845ee404bbc0b4a85686f15eb3001ffddda3db4e9baa265bf136",
"sha256:68aeea369a8130259354b6ba1fa9babe0c5ee6bced505dea4afcd00f765ae38b"
],
"index": "pypi",
"version": "==2.0.8"
},
"django-auth-ldap": {
"hashes": [
"sha256:72848b3b036d299114be3c6ef38b12f83f6cf1cf1696c5f92e06fe45a1b8e27b",
"sha256:f96398ce957bb8aaaee2e9d93e3eaf9955ab95a6d342303b5519350eb834f5d1"
],
"index": "pypi",
"version": "==1.7.0"
},
"django-extensions": {
"hashes": [
"sha256:c3c8524056ab1174c6fc769b0599e60f7e7797ce34f38db2effc48885a0a982c",
"sha256:e279344c6d825adb27694755aa924702d3cc401f8fad5ad9ef2d577948778a5f"
],
"index": "pypi",
"version": "==2.1.0"
},
"django-suit": {
"hashes": [
"sha256:19ed865a478dfca81cb5f50a70317700dd70da92c465093251d0e14330a2b92b"
],
"index": "pypi",
"version": "==0.2.26"
},
"djangorestframework": {
"hashes": [
"sha256:b6714c3e4b0f8d524f193c91ecf5f5450092c2145439ac2769711f7eba89a9d9",
"sha256:c375e4f95a3a64fccac412e36fb42ba36881e52313ec021ef410b40f67cddca4"
],
"index": "pypi",
"version": "==3.8.2"
},
"gunicorn": {
"hashes": [
"sha256:aa8e0b40b4157b36a5df5e599f45c9c76d6af43845ba3b3b0efe2c70473c2471",
"sha256:fa2662097c66f920f53f70621c6c58ca4a3c4d3434205e608e121b5b3b71f4f3"
],
"index": "pypi",
"version": "==19.9.0"
},
"ipaddress": {
"hashes": [
"sha256:64b28eec5e78e7510698f6d4da08800a5c575caa4a286c93d651c5d3ff7b6794",
"sha256:b146c751ea45cad6188dd6cf2d9b757f6f4f8d6ffb96a023e6f2e26eea02a72c"
],
"index": "pypi",
"version": "==1.0.22"
},
"mysqlclient": {
"hashes": [
"sha256:ff8ee1be84215e6c30a746b728c41eb0701a46ca76e343af445b35ce6250644f"
],
"index": "pypi",
"version": "==1.3.13"
},
"pyasn1": {
"hashes": [
"sha256:0ad0fe0593dde1e599cac0bf65bb1a4ec663032f0bc68ee44850db4251e8c501",
"sha256:13794d835643ee970b2c059dbfe4eb5d751e16c693c8baee61c526abd209e5c7",
"sha256:49a8ed515f26913049113820b462f698e6ed26df62c389dafb6fa3685ddca8de",
"sha256:74ac8521a0480f228549be20bea555ae35678f0e754c2fbc6f1576b0959bec43",
"sha256:89399ca8ecd4524f974e926d4ef9e7a787903e01f0a9cdff3131ad1361792fe5",
"sha256:8f291e0338d519a1a0d07f0b9d03c9265f6be26eb32fdd21af6d3259d14ea49c",
"sha256:b9d3abc5031e61927c82d4d96c1cec1e55676c1a991623cfed28faea73cdd7ca",
"sha256:d3bbd726c1a760d4ca596a4d450c380b81737612fe0182f5bb3caebc17461fd9",
"sha256:dea873d6c907c1cf1341fd88742a61efce33227d7743cb37564ab7d7e77dd9fd",
"sha256:ded5eea5cb88bc1ce9aa074b5a3092f95ce4741887e317e9b49c7ece75d7ea0e",
"sha256:e8b69ea2200d42201cbedd486eedb8980f320d4534f83ce2fb468e96aa5545d0",
"sha256:edad117649643230493aeb4955456ce19ab4b12e94489dde6f7094cdb5a3c87e",
"sha256:f58f2a3d12fd754aa123e9fa74fb7345333000a035f3921dbdaa08597aa53137"
],
"version": "==0.4.4"
},
"pyasn1-modules": {
"hashes": [
"sha256:077250b34432520430bc1c80dcbda4e354090785567c33ded35faa6df8d24753",
"sha256:0da2f947e8ad2697e86fe5fd0e55a4093a2fd79d839c9e19c34e28097db7002c",
"sha256:35ff894a0b5df8e28b700126b2869c7dcfb2b2db5bc82e5d5e82547069241553",
"sha256:44688b94841349648b1e1a5a7a3d96e6596d5d4f21d0b59a82307e153c4dc74b",
"sha256:833716dde880a7f2f2ccdeea9a096842626981ff2a477d8b318c0906367ac11b",
"sha256:a0cf3e1842e7c60fde97cb22d275eb6f9524f5c5250489e292529de841417547",
"sha256:a38a8811ea784c0136abfdba73963876328f66172db21a05a82f9515909bfb4e",
"sha256:a728bb9502d1fdc104c66f24a176b6a70a32e89d1d8a5b55c959233ed51c67be",
"sha256:c30a098435ea0989c37005a971843e9d3966c7f6d056ddbf052e5061c06e3291",
"sha256:c355a45b32c5bc1d9893eceb704b0cfcd1126f91b5a7b9ee64c1c05383283381",
"sha256:e64679de1940f41ead5170fce364d54e7b9e2e862f064727b6bcb5cee753b7a2",
"sha256:ed71d20225c356881c29f0b1d7a0d6521563a389d9478e8f95d798cc5ba07b88",
"sha256:f183f0940b9f5ed2ad9d04c80cab2451440fa9af4fc959d85113fadd2e777962"
],
"version": "==0.2.2"
},
"python-ldap": {
"hashes": [
"sha256:41975e79406502c092732c57ef0c2c2eb318d91e8e765f81f5d4ab6c1db727c5"
],
"version": "==3.1.0"
},
"pytz": {
"hashes": [
"sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053",
"sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"
],
"version": "==2018.5"
},
"raven": {
"hashes": [
"sha256:3fd787d19ebb49919268f06f19310e8112d619ef364f7989246fc8753d469888",
"sha256:95f44f3ea2c1b176d5450df4becdb96c15bf2632888f9ab193e9dd22300ce46a"
],
"index": "pypi",
"version": "==6.9.0"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
}
},
"develop": {}
}
+11 -36
View File
@@ -1,46 +1,21 @@
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
Install system dependencies: virtualenv venv
. venv/bin/activate
sudo apt install python-dev libldap2-dev libsasl2-dev bind9-utils pip install -r requirements.txt
python manage.py migrate
Use [mise](https://mise.jdx.dev/) to manage Python and uv: python manage.py runserver
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:
uv run python manage.py dumpdata mdb --natural-foreign --indent=4 > mdb/fixtures/test_data.json 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
- Netboots hosts marked PXE installable, by setting their DHCP boot filename * Creates PXE config files (with random secret for use with Puppet autosign)
- Partial IPv6 support * Partial IPv6 support
-1
View File
@@ -5,5 +5,4 @@ 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
@@ -0,0 +1 @@
default_app_config = 'mdb.apps.MdbAppConfig'
+66 -189
View File
@@ -1,45 +1,12 @@
import ipaddress import ipaddress
from django.conf import settings
from django.contrib import admin, messages from django.contrib import admin, messages
from django.db import models
from django.db.models import Count from django.db.models import Count
from django.forms import NumberInput from mdb.forms import InterfaceForm
from mdb.forms import InterfaceForm, SerialNumberInput
from mdb.models import ( from mdb.models import (
DhcpConfig, Ip6Address, Interface, Ip4Address, DhcpOption, DhcpCustomField, DomainSrvRecord, DomainTxtRecord,
DhcpCustomField, DomainCnameRecord, DomainARecord, Domain, Host, Ip4Subnet, Ip6Subnet, Nameserver, MailExchange, OperatingSystem,
DhcpOption, HostType, DhcpConfig, DomainAAAARecord)
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):
@@ -54,121 +21,88 @@ class InterfaceInline(admin.TabularInline):
form = InterfaceForm form = InterfaceForm
def get_queryset(self, request): def get_queryset(self, request):
return super().get_queryset(request).select_related("host", "domain") return super(InterfaceInline, self).get_queryset(request).select_related()
@admin.register(Host)
class HostAdmin(admin.ModelAdmin): class HostAdmin(admin.ModelAdmin):
ordering = ("hostname",) ordering = ('hostname',)
inlines = [InterfaceInline] inlines = [InterfaceInline]
list_display = [ list_display = ['hostname', 'owner', 'host_type', 'location', 'mac_addresses', 'ip_addresses', 'in_domain',
"hostname", 'pxe_installable', 'ipv6_enabled']
"owner", list_filter = ['host_type', 'owner', 'location', 'pxe_installable']
"host_type", readonly_fields = ['kerberos_principal_name', 'kerberos_principal_created_date',
"location", 'kerberos_principal_created']
"mac_addresses", search_fields = ['hostname', 'location', 'interface__macaddr', 'interface__ip4address__address']
"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", {"fields": ("owner", "location", "description")}), ('Owner Information', {
( 'fields': ('owner', 'location', 'description')
"Hardware and Software Information", }),
{ ('Hardware and Software Information', {
"fields": ( 'fields': (
("hostname", "host_type"), ('hostname', 'host_type'),
("pxe_key", "pxe_installable"), ('pxe_key', 'pxe_installable'),
("brand", "model"), ('brand', 'model'),
"serial_number", 'serial_number',
("operating_system", "virtual"), ('operating_system', 'virtual'))
"allow_email", }),
)
},
),
) )
actions = ["set_installable"] actions = ['set_installable']
def mac_addresses(self, host): def mac_addresses(self, host):
addresses = host.interface_set.filter(macaddr__isnull=False).values_list( addresses = host.interface_set.filter(macaddr__isnull=False).values_list('macaddr', flat=True)
"macaddr", flat=True
)
return ", ".join(addresses) return ", ".join(addresses)
def ip_addresses(self, host): def ip_addresses(self, host):
addresses = host.interface_set.filter(ip4address__isnull=False).values_list( addresses = host.interface_set.filter(ip4address__isnull=False).values_list('ip4address__address', flat=True)
"ip4address__address", flat=True
)
return ", ".join(addresses) return ", ".join(addresses)
@admin.display(description="in domains")
def in_domain(self, host): def in_domain(self, host):
domains = host.interface_set.values_list("domain__domain_name", flat=True) domains = host.interface_set.values_list('domain__domain_name', flat=True)
return ",".join(domains) return ",".join(domains)
@admin.display(boolean=True) in_domain.short_description = "in domains"
def ipv6_enabled(self, host): def ipv6_enabled(self, host):
ipv6_ifs = host.interface_set.annotate(num_ipv6=Count("ip6address")).filter( ipv6_ifs = host.interface_set.annotate(num_ipv6=Count('ip6address')).filter(num_ipv6__gt=0)
num_ipv6__gt=0
)
return ipv6_ifs.exists() return ipv6_ifs.exists()
ipv6_enabled.boolean = True
# FIXME: custom filter to sort custom field # FIXME: custom filter to sort custom field
@staticmethod @staticmethod
def _get_host_warning_message(host, ifs): def _get_host_warning_message(host, ifs):
ifs_str = ", ".join([_if.name for _if in 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." return 'Host {} might not be installable since interface(s) {} has no IP set.'.format(host, ifs_str)
@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( invalid_ifs_per_host.append(self._get_host_warning_message(obj, invalid_ifs))
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( self.message_user(request, message='\n'.join(invalid_ifs_per_host), level=messages.WARNING)
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 ( return super(HostAdmin, self).get_queryset(request).select_related()
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): def assigned_to_host(self, addr):
return addr.interface.host return addr.interface.host
assigned_to_host.short_description = "Assigned to Host"
class DhcpOptionInline(admin.TabularInline): class DhcpOptionInline(admin.TabularInline):
model = DhcpOption model = DhcpOption
@@ -180,76 +114,34 @@ class DhcpCustomFieldInline(admin.TabularInline):
extra = 0 extra = 0
@admin.register(Ip4Subnet) class SubnetAdmin(admin.ModelAdmin):
class Ip4SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin): list_display = ['name', 'network', 'netmask', 'num_addresses', 'broadcast_address', 'first_address', 'last_address']
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): def num_addresses(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask) subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return subnet.num_addresses return subnet.num_addresses
@admin.display(description="broadcast")
def broadcast_address(self, ip4subnet): def broadcast_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask) subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return subnet.broadcast_address return subnet.broadcast_address
@admin.display(description="first address")
def first_address(self, ip4subnet): def first_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask) subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return next(subnet.hosts()) return next(subnet.hosts())
@admin.display(description="last address")
def last_address(self, ip4subnet): def last_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask) subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return list(subnet.hosts())[-1] return list(subnet.hosts())[-1]
broadcast_address.short_description = 'broadcast'
num_addresses.short_description = '#addresses'
first_address.short_description = 'first address'
last_address.short_description = 'last address'
def get_queryset(self, request): def get_queryset(self, request):
# TODO: optimize # TODO: optimize
return super().get_queryset(request) return super(SubnetAdmin, self).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):
@@ -277,56 +169,41 @@ class DomainAAAARecordInline(admin.TabularInline):
extra = 0 extra = 0
@admin.register(Domain) class DomainAdmin(admin.ModelAdmin):
class DomainAdmin(SerialFieldAdminMixin, admin.ModelAdmin): inlines = [DomainSrvRecordInline, DomainTxtRecordInline, DomainARecordInline, DomainAAAARecordInline,
inlines = [ DomainCnameRecordInline]
DomainSrvRecordInline, list_display = ['domain_name', 'domain_soa', 'domain_admin', 'num_records', 'domain_ipaddr', 'domain_ip6addr']
DomainTxtRecordInline, search_fields = ['domain_name']
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): def num_records(self, domain):
host_a_records = Host.objects.filter(interface__domain=domain).count() host_a_records = Host.objects.filter(interface__domain=domain).count()
size = { size = {
"CNAME": domain.domaincnamerecord_set.count(), "CNAME": domain.domaincnamerecord_set.count(),
"SRV": domain.domainsrvrecord_set.count(), "SRV": domain.domainsrvrecord_set.count(),
"TXT": domain.domaintxtrecord_set.count(), "TXT": domain.domaintxtrecord_set.count(),
"A": domain.domainarecord_set.count() + host_a_records, "A": domain.domainarecord_set.count() + host_a_records
} }
return ", ".join([f"{k}: {v}" for k, v in size.items()]) return ', '.join(['{}: {}'.format(k, v) for k, v in size.items()])
num_records.short_description = "Num Records"
@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 = [ list_display = ['name', 'version', 'arch', 'pxe_kernel', 'pxe_initrd', 'pxe_preseed_config_url']
"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)
+13 -21
View File
@@ -10,15 +10,13 @@ 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( return Host.objects.get(hostname=host_name, interface__domain__domain_name=domain)
hostname=host_name, interface__domain__domain_name=domain except (Host.DoesNotExist, Host.MultipleObjectsReturned):
)
except Host.DoesNotExist, Host.MultipleObjectsReturned:
return None return None
def validate(self, attrs): def validate(self, attrs):
@@ -26,24 +24,18 @@ 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( raise ValidationError('Host \'{}\' is marked as not installable via PXE.'.format(attrs['certname']))
"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( raise ValidationError('Supplied pxe_key \'{}\' does not match host \'{}\'.'.format(
"Supplied pxe_key '{}' does not match host '{}'.".format( attrs['pxe_key'],
attrs["pxe_key"], attrs["certname"] attrs['certname']))
)
)
self.host = host self.host = host
@@ -52,7 +44,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.pxe_key = ''
self.host.save() self.host.save()
return self.host return self.host
+1 -1
View File
@@ -17,4 +17,4 @@ class HostPXEValidate(APIView):
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})
+2 -2
View File
@@ -2,8 +2,8 @@ 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
+11 -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.581Z", "created_date": "2015-10-03T13:02:53.581",
"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.810Z", "created_date": "2015-10-04T17:13:46.810",
"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.807Z", "created_date": "2015-10-04T17:13:46.807",
"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.809Z", "created_date": "2015-10-04T17:13:46.809",
"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.248Z", "created_date": "2015-10-04T12:50:12.248",
"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.765Z", "created_date": "2015-10-03T13:08:17.765",
"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,
@@ -2740,6 +2740,9 @@
}, },
{ {
"fields": { "fields": {
"pxe_initrd": "path-to-initrd-for-os",
"pxe_kernel": "path-to-kernel-for-os",
"pxe_preseed_config_url": "http://url-to-preseed.config",
"arch": "x86_64", "arch": "x86_64",
"name": "Ubuntu", "name": "Ubuntu",
"version": "16.04-beta-final" "version": "16.04-beta-final"
@@ -2760,7 +2763,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.061Z", "created_date": "2015-10-03T13:23:37.061",
"owner": "edb", "owner": "edb",
"serial_number": "12345678", "serial_number": "12345678",
"model": "OptiPlex 760", "model": "OptiPlex 760",
@@ -2779,7 +2782,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.063Z" "created_date": "2015-10-03T13:23:37.063"
}, },
"model": "mdb.interface", "model": "mdb.interface",
"pk": 1 "pk": 1
+13 -70
View File
@@ -1,91 +1,34 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.forms import ModelChoiceField, NumberInput from django.forms import ModelChoiceField
from django.utils.safestring import mark_safe
from mdb.models import Interface, Ip4Address 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): class Ip4ModelChoiceField(ModelChoiceField):
def label_from_instance(self, obj): def label_from_instance(self, obj):
if not hasattr(obj, "interface"): if not hasattr(obj, 'interface'):
return obj.address return obj.address
return f"{obj.address} ({obj.interface.host.hostname})" return "{} ({})".format(obj.address, obj.interface.host.hostname)
class InterfaceForm(forms.ModelForm): class InterfaceForm(forms.ModelForm):
class Meta: model = Interface
model = Interface
fields = "__all__"
def clean(self): def clean(self):
cleaned_data = super().clean() macaddr = self.cleaned_data['macaddr']
# Missing when the field itself failed validation, and the address is optional. ip4address = self.cleaned_data['ip4address']
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. for interface in Interface.objects.filter(macaddr=macaddr):
clashes = Interface.objects.filter( subnet = interface.ip4address.subnet
macaddr__iexact=macaddr, ip4address__subnet=ip4address.subnet if subnet == ip4address.subnet and interface != self.instance:
).exclude(pk=self.instance.pk) raise ValidationError('Macaddr already in this subnet')
if clashes.exists():
raise ValidationError("Macaddr already in this subnet")
return cleaned_data
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super(InterfaceForm, self).__init__(*args, **kwargs)
# Join Ip4Address with tables interface and host, # Join Ip4Address with tables interface and host,
# before printing the select labels that walk those two relations. # before printing the select labels that walk those two relations.
queryset = Ip4Address.objects.select_related("interface", "interface__host") 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)
self.fields["ip4address"] = Ip4ModelChoiceField(
queryset=queryset, required=False
)
+101
View File
@@ -0,0 +1,101 @@
import os
import sys
from difflib import context_diff
from django.core.management.base import BaseCommand
from django.core.mail import mail_admins
from subprocess import getstatusoutput
from mdb.models import DhcpConfig
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 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 = "/usr/sbin/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)
diff = self.calculate_diff(config, new, old)
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)
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=''))
-83
View File
@@ -1,83 +0,0 @@
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,
)
+180
View File
@@ -0,0 +1,180 @@
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 subprocess import getstatusoutput
from mdb.models import Domain, Ip4Subnet, Ip6Subnet
class Command(BaseCommand):
args = '[--force] [--debug]'
help = 'Generates the bind configuration'
rndc_bin = "/usr/sbin/rndc"
reload_command = "%s reload" % rndc_bin
freeze_command = "%s freeze" % rndc_bin
thaw_command = "%s thaw" % rndc_bin
commands_in_order = [freeze_command, reload_command, thaw_command]
checkzone_bin = "/usr/sbin/named-checkzone"
checkzone_dir = "/tmp/mdb-checkzone"
debug = False
force = False
debug_dir = '/tmp/mdb-debug'
changes = {}
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.')
def handle(self, *args, **options):
self.debug = options['debug']
self.force = options['force']
# do the binaries exist?
self.validate_binaries_exist()
# create directories if needed
os.path.isdir(self.checkzone_dir) or os.mkdir(self.checkzone_dir)
self.debug and (os.path.isdir(self.debug_dir) or os.mkdir(self.debug_dir))
# update zones with new serials
self.update_zone_serials()
# 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 self.debug:
sys.stdout.write("reloading zones... ")
for command in self.commands_in_order:
status, output = getstatusoutput(command)
if status != 0:
sys.stdout.write("fail\n")
message = (
"Failed to reload zones, please inspect...\n\n" +
"%s@%s$ %s" % (
os.getlogin(), os.uname()[1], 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)
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_serials(self):
for zone in chain(Domain.objects.all(),
Ip4Subnet.objects.all(),
Ip6Subnet.objects.all()):
if zone.domain_serial == zone.domain_active_serial and not self.force:
continue
if self.update_zone(zone):
self.changes[zone.domain_name]['success'] = True
def validate_binaries_exist(self):
for f in (self.checkzone_bin, self.rndc_bin):
if not os.path.isfile(f) and not self.debug:
print("ERROR: no such file %s, exiting..." % f)
if not self.debug:
sys.exit(1)
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 = ''
self.changes[zone.domain_name]['diff'] = self.calculate_diff(new, old, zone)
# 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 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("%s %s %s" % (self.checkzone_bin, zone.domain_name, zonefile))
-215
View File
@@ -1,215 +0,0 @@
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)
+2
View File
@@ -1,4 +1,6 @@
# -*- 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
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
@@ -1,4 +1,6 @@
# -*- 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,4 +1,6 @@
# -*- 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,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
@@ -1,4 +1,6 @@
# -*- 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,4 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
@@ -1,4 +1,6 @@
# -*- 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,4 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
@@ -1,18 +0,0 @@
# 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
@@ -1,18 +0,0 @@
# 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),
),
]
@@ -1,39 +0,0 @@
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
@@ -1,25 +0,0 @@
# 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',
),
]
+158 -210
View File
@@ -1,19 +1,11 @@
import datetime
import ipaddress
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from mdb.pxe import host_as_pxe_files
from mdb.validators import validate_hostname, validate_macaddr from mdb.validators import validate_hostname, validate_macaddr
# The boot file of a PXE installable host. UEFI x86-64 clients, PXE architecture 7 or 9 import ipaddress
# in their vendor class, get the UEFI loader instead. import datetime
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")
)
class Nameserver(models.Model): class Nameserver(models.Model):
@@ -42,10 +34,8 @@ 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( domain_ip6addr = models.GenericIPAddressField(protocol='IPv6', blank=True, null=True)
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)
@@ -56,39 +46,38 @@ class Domain(models.Model):
return self.domain_name return self.domain_name
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 = f"; serial:{self.domain_serial:d}\n" content = "; serial:%d\n" % self.domain_serial
content += f"; zone file for {self.domain_name}\n" content += "; zone file for %s\n" % self.domain_name
content += f"; {datetime.datetime.now()}\n" content += "; %s\n" % datetime.datetime.now()
content += f"; filename: {self.domain_filename}\n" content += "; filename: %s\n" % self.domain_filename
content += f"$TTL {self.domain_ttl}\n" content += "$TTL %s\n" % self.domain_ttl
content += "@ IN SOA {}. {}. (\n".format( content += "@ IN SOA %s. %s. (\n" % (
self.domain_soa, self.domain_soa,
self.domain_admin.replace("@", "."), self.domain_admin.replace("@", "."))
) content += "\t%d\t; serial\n" % self.domain_serial
content += f"\t{self.domain_serial:d}\t; serial\n" content += "\t%d\t; refresh\n" % self.domain_refresh
content += f"\t{self.domain_refresh:d}\t; refresh\n" content += "\t%d\t; retry\n" % self.domain_retry
content += f"\t{self.domain_retry:d}\t; retry\n" content += "\t%d\t; expire\n" % self.domain_expire
content += f"\t{self.domain_expire:d}\t; expire\n" content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
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 += f"@\tIN\tNS\t{nameserver.hostname}.\n" content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
for mx in self.domain_mailexchanges.all(): for mx in self.domain_mailexchanges.all():
content += f"@\t\tMX\t{mx.priority:d} {mx.hostname}.\n" content += "@\t\tMX\t%d %s.\n" % (mx.priority, mx.hostname)
if self.domain_ipaddr is not None: if self.domain_ipaddr is not None:
content += f"@\tIN\tA\t{self.domain_ipaddr}\n" content += "@\tIN\tA\t%s\n" % self.domain_ipaddr
if self.domain_ip6addr: if self.domain_ip6addr:
content += f"@\tIN\tAAAA\t{self.domain_ip6addr}\n" content += "@\tIN\tAAAA\t%s\n" % self.domain_ip6addr
content += "; SRV records\n" content += "; SRV records\n"
@@ -108,7 +97,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 += f"{cname.name}\tIN\tCNAME\t{cname.target}\n" content += "%s\tIN\tCNAME\t%s\n" % (cname.name, cname.target)
content += "; TXT records \n" content += "; TXT records \n"
@@ -120,17 +109,13 @@ 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 += ( content += "%-20s\tIN\tA\t%s\n" % (
f"{host.hostname:<20}\tIN\tA\t{interface.ip4address.address}\n" host.hostname,
) interface.ip4address.address)
for ipv6addr in interface.ip6address_set.all(): for ipv6addr in interface.ip6address_set.all():
content += f"{host.hostname:<20}\tIN\tAAAA\t{ipv6addr.full_address()}\n" content += "%-20s\tIN\tAAAA\t%s\n" % (
host.hostname,
if host.allow_email and interface.ip4address: ipv6addr.full_address())
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
@@ -143,11 +128,20 @@ 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, models.CASCADE) domain = models.ForeignKey(Domain, on_delete=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 f"{self.srvce}.{self.prot}.{self.name}. IN SRV {self.priority} {self.weight} {self.port} {self.target}." return '{}.{}.{}. IN SRV {} {} {} {}.'.format(
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()
@@ -155,15 +149,12 @@ class DomainSrvRecord(models.Model):
class DomainTxtRecord(models.Model): class DomainTxtRecord(models.Model):
name = models.CharField(max_length=256) name = models.CharField(max_length=256)
# note that the max length of a TXT record is 255, but target = models.CharField(max_length=256)
# bind allows for longer records by splitting them domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
# 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 f"{self.name} TXT {self.target}" return '{} TXT {}'.format(self.name, self.target)
def __str__(self): def __str__(self):
return self.as_record() return self.as_record()
@@ -172,11 +163,11 @@ class DomainTxtRecord(models.Model):
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, models.CASCADE) domain = models.ForeignKey(Domain, on_delete=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 f"{self.name} IN CNAME {self.target}" return '{} IN CNAME {}'.format(self.name, self.target)
def __str__(self): def __str__(self):
return self.as_record() return self.as_record()
@@ -184,12 +175,12 @@ class DomainCnameRecord(models.Model):
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, models.CASCADE) domain = models.ForeignKey(Domain, on_delete=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 f"{self.name} IN A {self.target}" return '{} IN A {}'.format(self.name, self.target)
def __str__(self): def __str__(self):
return self.as_record() return self.as_record()
@@ -197,12 +188,12 @@ class DomainARecord(models.Model):
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, models.CASCADE) domain = models.ForeignKey(Domain, on_delete=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 f"{self.name} IN AAAA {self.target}" return '{} IN AAAA {}'.format(self.name, self.target)
def __str__(self): def __str__(self):
return self.as_record() return self.as_record()
@@ -219,56 +210,45 @@ 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 = f"# Autogenerated configuration {datetime.datetime.now()}\n" content = "# Autogenerated configuration %s\n" % datetime.datetime.now()
if self.authoritative: if self.authoritative:
content += "authoritative;\n" content += "authoritative;\n"
content += f"default-lease-time {self.default_lease_time:d};\n" content += "default-lease-time %d;\n" % self.default_lease_time
content += f"max-lease-time {self.max_lease_time:d};\n" content += "max-lease-time %d;\n" % self.max_lease_time
content += f"log-facility {self.log_facility};\n" content += "log-facility %s;\n" % self.log_facility
content += f"ddns-update-style {self.ddns_update_style};\n" content += "ddns-update-style %s;\n" % self.ddns_update_style
# 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 += f"\n# {subnet.name}\n" content += "\n# %s\n" % subnet.name
content += f"subnet {subnet.network} netmask {subnet.netmask} {{\n" content += "subnet %s netmask %s {\n" % (
subnet.network,
subnet.netmask)
for option in subnet.dhcpoption_set.all(): for option in subnet.dhcpoption_set.all():
content += f"\toption {option.key} {option.value};\n" content += "\toption %s %s;\n" % (option.key, option.value)
for option in subnet.dhcpcustomfield_set.all(): for option in subnet.dhcpcustomfield_set.all():
content += f"\t{option.value};\n" content += "\t%s;\n" % option.value
if subnet.dhcp_dynamic: if subnet.dhcp_dynamic:
content += ( content += "\trange %s %s;\n" \
f"\trange {subnet.dhcp_dynamic_start} {subnet.dhcp_dynamic_end};\n" % (subnet.dhcp_dynamic_start, subnet.dhcp_dynamic_end)
)
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 ( if not hasattr(ip4address, 'interface') or not ip4address.interface.dhcp_client:
not hasattr(ip4address, "interface")
or not ip4address.interface.dhcp_client
):
continue continue
_if = ip4address.interface _if = ip4address.interface
content += f"\nhost {_if.host.hostname} {{\n" content += "\nhost %s {\n" % _if.host.hostname
content += f"\thardware ethernet {_if.macaddr};\n" content += "\thardware ethernet %s;\n" % _if.macaddr
content += ( content += "\tfixed-address %s.%s;\n" % \
f"\tfixed-address {_if.host.hostname}.{_if.domain.domain_name};\n" (_if.host.hostname, _if.domain.domain_name)
) if len(_if.pxe_filename) > 0:
if _if.pxe_filename == PXE_FILENAME: content += "\tfilename \"%s\";\n" % _if.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
@@ -277,7 +257,7 @@ class DhcpConfig(models.Model):
return self.name return self.name
class Meta: class Meta:
verbose_name = "DHCP config" verbose_name = 'DHCP config'
class Ip6Subnet(models.Model): class Ip6Subnet(models.Model):
@@ -301,38 +281,25 @@ 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 += f"; zone file for {self.domain_name}\n" content += "; zone file for %s\n" % self.domain_name
content += f"; {datetime.datetime.now()}\n" content += "; %s\n" % datetime.datetime.now()
content += f"; filename: {self.domain_filename}\n" content += "; filename: %s\n" % self.domain_filename
content += f"$TTL {self.domain_ttl}\n" content += "$TTL %s\n" % self.domain_ttl
content += "@ IN SOA {}. {}. (\n".format( content += "@ IN SOA %s. %s. (\n" % (
self.domain_soa, self.domain_soa,
self.domain_admin.replace("@", "."), self.domain_admin.replace("@", "."))
) content += "\t%d\t; serial\n" % self.domain_serial
content += f"\t{self.domain_serial:d}\t; serial\n" content += "\t%d\t; refresh\n" % self.domain_refresh
content += f"\t{self.domain_refresh:d}\t; refresh\n" content += "\t%d\t; retry\n" % self.domain_retry
content += f"\t{self.domain_retry:d}\t; retry\n" content += "\t%d\t; expire\n" % self.domain_expire
content += f"\t{self.domain_expire:d}\t; expire\n" content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
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 += f"@\tIN\tNS\t{nameserver.hostname}.\n" content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
content += ";\n" content += ";\n"
@@ -345,13 +312,13 @@ class Ip6Subnet(models.Model):
if addr.interface.domain is None: if addr.interface.domain is None:
continue continue
hostname = ( hostname = "%s.%s" % (
f"{addr.interface.host.hostname}.{addr.interface.domain.domain_name}" addr.interface.host.hostname,
) 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 += f"{ip}\tPTR\t{hostname}.\n" content += "%s\tPTR\t%s.\n" % (ip, hostname)
# 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)
@@ -359,13 +326,13 @@ class Ip6Subnet(models.Model):
return content return content
class Meta: class Meta:
verbose_name = "IPv6 subnet" verbose_name = 'IPv6 subnet'
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)
@@ -381,98 +348,78 @@ 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( dhcp_dynamic_start = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True)
protocol="IPv4", null=True, blank=True dhcp_dynamic_end = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True)
) dhcp_config = models.ForeignKey(DhcpConfig, on_delete=models.CASCADE)
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 + ")"
@property
def reverse_zone_name(self):
"""The in-addr.arpa reverse zone name for this subnet.
Masks of /24 or shorter give the standard octet-boundary name
(190.36.158.in-addr.arpa). Masks longer than /24 give the RFC 2317
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
named.conf or rndc refuses to load the file.
"""
net = ipaddress.IPv4Network(f"{self.network}/{self.netmask}", strict=False)
o1, o2, o3, o4 = str(net.network_address).split(".")
rev = f"{o3}.{o2}.{o1}"
if net.prefixlen > 24:
return f"{o4}-{net.prefixlen:d}.{rev}.in-addr.arpa"
return f"{rev}.in-addr.arpa"
def zone_file_contents(self, generate_unassigned=False): def zone_file_contents(self, generate_unassigned=False):
content = "" content = ""
content += f"; zone file for {self.domain_name}\n" content += "; zone file for %s\n" % self.domain_name
content += f"; {datetime.datetime.now()}\n" content += "; %s\n" % datetime.datetime.now()
content += f"; filename: {self.domain_filename}\n" content += "; filename: %s\n" % self.domain_filename
content += f"$TTL {self.domain_ttl}\n" content += "$TTL %s\n" % self.domain_ttl
content += "@ IN SOA {}. {}. (\n".format( content += "@ IN SOA %s. %s. (\n" % (
self.domain_soa, self.domain_soa,
self.domain_admin.replace("@", "."), self.domain_admin.replace("@", "."))
) content += "\t%d\t; serial\n" % self.domain_serial
content += f"\t{self.domain_serial:d}\t; serial\n" content += "\t%d\t; refresh\n" % self.domain_refresh
content += f"\t{self.domain_refresh:d}\t; refresh\n" content += "\t%d\t; retry\n" % self.domain_retry
content += f"\t{self.domain_retry:d}\t; retry\n" content += "\t%d\t; expire\n" % self.domain_expire
content += f"\t{self.domain_expire:d}\t; expire\n" content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
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 += f"@\tIN\tNS\t{nameserver.hostname}.\n" content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
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 = f"{addr.interface.host.hostname}.{addr.interface.domain.domain_name}" hostname = "%s.%s" % (
content += f"{addr.address.split('.')[3]:<20}\tIN\tPTR\t{hostname}.\n" addr.interface.host.hostname,
elif not hasattr(addr, "interface") and generate_unassigned: addr.interface.domain.domain_name)
content += "{}\tIN\tPTR\t{}.{}\n".format( content += "%-20s\tIN\tPTR\t%s.\n" % \
(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'
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, models.CASCADE) ip4subnet = models.ForeignKey(Ip4Subnet, on_delete=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, models.CASCADE) ip4subnet = models.ForeignKey(Ip4Subnet, on_delete=models.CASCADE)
class Meta: class Meta:
verbose_name = "DHCP custom field" verbose_name = 'DHCP custom field'
class Ip4Address(models.Model): class Ip4Address(models.Model):
subnet = models.ForeignKey(Ip4Subnet, models.CASCADE) subnet = models.ForeignKey(Ip4Subnet, on_delete=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)
@@ -480,8 +427,8 @@ class Ip4Address(models.Model):
return self.address return self.address
class Meta: class Meta:
verbose_name = "IPv4 address" verbose_name = 'IPv4 address'
verbose_name_plural = "IPv4 addresses" verbose_name_plural = 'IPv4 addresses'
class HostType(models.Model): class HostType(models.Model):
@@ -500,21 +447,23 @@ class HostType(models.Model):
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( arch = models.CharField(max_length=255, choices=OS_ARCHITECTURES, blank=True, null=True)
max_length=255, choices=OS_ARCHITECTURES, blank=True, null=True
) pxe_kernel = models.CharField(max_length=255, blank=True, null=True)
pxe_initrd = models.CharField(max_length=255, blank=True, null=True)
pxe_preseed_config_url = models.URLField(blank=True, null=True)
def __str__(self): def __str__(self):
return self.name + " " + self.version + " (" + self.arch + ")" return self.name + " " + self.version + " (" + self.arch + ")"
@@ -532,19 +481,14 @@ 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, models.CASCADE) # FIXME: SET_NULL? host_type = models.ForeignKey(HostType, on_delete=models.CASCADE)
virtual = models.BooleanField(default=False) virtual = models.BooleanField(default=False)
allow_email = models.BooleanField(default=False) operating_system = models.ForeignKey(OperatingSystem, on_delete=models.CASCADE)
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( kerberos_principal_created_date = models.DateTimeField(null=True, blank=True, editable=False)
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)
@@ -552,37 +496,41 @@ class Host(models.Model):
def __str__(self): def __str__(self):
return self.hostname return self.hostname
def as_pxe_files(self):
return host_as_pxe_files(self)
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, models.CASCADE) host = models.ForeignKey(Host, on_delete=models.CASCADE)
ip4address = models.OneToOneField( ip4address = models.OneToOneField(Ip4Address, blank=True, null=True, on_delete=models.SET_NULL)
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, models.CASCADE) domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
def __str__(self): def __str__(self):
return f"{self.macaddr} ({self.name} on {self.host.hostname})" return "%s (%s on %s)" % (self.macaddr, self.name, self.host.hostname)
def ipv6_enabled(self): def ipv6_enabled(self):
return self.ip6address_set.exists() return self.ip6address_set.exists()
class Ip6Address(models.Model): class Ip6Address(models.Model):
subnet = models.ForeignKey(Ip6Subnet, models.CASCADE) subnet = models.ForeignKey(Ip6Subnet, on_delete=models.CASCADE)
address = models.GenericIPAddressField(protocol="IPv6") address = models.GenericIPAddressField(protocol='IPv6')
interface = models.ForeignKey(Interface, models.CASCADE) interface = models.ForeignKey(Interface, on_delete=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 f"{self.full_address()} ({self.interface.name} on {self.interface.host.hostname})" return "%s (%s on %s)" % (
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'
+58
View File
@@ -0,0 +1,58 @@
from django.conf import settings
from django.template.loader import render_to_string
import ipaddress
def render_pxelinux_cfg(_if, host):
"""Renders pxelinux.cfg based on a host and the set of host interfaces.
If the host is tied to an OS with PXE fields set, it will use these instead of defaults."""
# Default config
kernel = settings.MDB_PXE_KERNEL
initrd = settings.MDB_PXE_INITRD
preseed_config_url = settings.MDB_PXE_PRESEED_URL
# Per host config (via OS model)
_os = host.operating_system
pxe_attrs = [_os.pxe_initrd, _os.pxe_kernel, _os.pxe_preseed_config_url]
if None not in pxe_attrs and '' not in pxe_attrs:
kernel = _os.pxe_kernel
initrd = _os.pxe_initrd
preseed_config_url = _os.pxe_preseed_config_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
View File
Whitespace-only changes.
-59
View File
@@ -1,59 +0,0 @@
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
@@ -1,50 +0,0 @@
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
@@ -1,247 +0,0 @@
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}")
+44 -34
View File
@@ -1,11 +1,12 @@
import ipaddress
import logging import logging
import os
import uuid import uuid
from django.conf import settings
from django.db.models.signals import post_save, pre_delete, pre_save from django.db.models.signals import pre_delete, post_save, pre_save
from django.dispatch import receiver from django.dispatch import receiver
import ipaddress
from mdb.models import PXE_FILENAME, Host, Interface, Ip4Address, Ip4Subnet, Ip6Subnet from mdb.models import Interface, Host, Ip6Subnet, Ip4Subnet, Ip4Address
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__)
@@ -33,7 +34,9 @@ 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:
instance.domain_name = instance.reverse_zone_name ipspl = instance.network.split(".")
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:
@@ -43,9 +46,7 @@ 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 = format_domain_serial_and_add_one(instance.dhcp_config.serial)
instance.dhcp_config.serial
)
instance.dhcp_config.save() instance.dhcp_config.save()
@@ -54,7 +55,8 @@ def set_domain_name_for_ipv6_subnet(sender, instance, **kwargs):
if len(instance.domain_name) > 0: if len(instance.domain_name) > 0:
return return
instance.domain_name = instance.reverse_zone_name network = ipaddress.IPv6Address("%s::" % instance.network)
instance.domain_name = ".".join(network.exploded.replace(":", "")[:16])[::-1] + ".ip6.arpa"
@receiver(post_save, sender=Interface) @receiver(post_save, sender=Interface)
@@ -75,22 +77,16 @@ 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 = format_domain_serial_and_add_one(domain.domain_serial)
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 = format_domain_serial_and_add_one(subnet.domain_serial)
subnet.domain_serial
)
subnet.save() subnet.save()
@receiver(post_save, sender=Host) @receiver(post_save, sender=Host)
def set_pxe_boot_when_host_changes(sender, instance, created, **kwargs): def create_pxe_key_and_write_pxe_files_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:
@@ -99,35 +95,49 @@ def set_pxe_boot_when_host_changes(sender, instance, created, **kwargs):
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 _if in host.interface_set.exclude(ip4address__isnull=True): for pxe_file_name, pxe_file in host.as_pxe_files():
changed = False path = os.path.join(settings.MDB_PXE_TFTP_ROOT, pxe_file_name)
if host.pxe_installable: if host.pxe_installable:
if not _if.pxe_filename: # Write pxeconfig file to disk
_if.pxe_filename = PXE_FILENAME with open(path, 'w+') as f:
changed = True f.write(pxe_file)
if not _if.dhcp_client: logger.info("Created or updated {}".format(path))
_if.dhcp_client = True os.chmod(path, 0o644) # Not writeable for all
changed = True
elif _if.pxe_filename == PXE_FILENAME: # Also set necessary fields for pxe installation
_if.pxe_filename = "" interfaces = host.interface_set.exclude(ip4address__isnull=True)
changed = True
if changed: for _if in interfaces:
_if.save() changed = False
if not _if.pxe_filename:
_if.pxe_filename = 'pxelinux.0' # don't overwrite
changed = True
if not _if.dhcp_client:
_if.dhcp_client = True
changed = True
if changed:
_if.save()
else:
if os.path.exists(path):
# Delete pxeconfig file from disk
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 = format_domain_serial_and_add_one(domain.domain_serial) domain.domain_serial += 1
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 = format_domain_serial_and_add_one(subnet.domain_serial) subnet.domain_serial += 1
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)
+7
View File
@@ -0,0 +1,7 @@
default ninja
prompt 0
timeout 0
label ninja
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 snop={{host.pxe_key}} initrd={{initrd}} -- quiet
+80
View File
@@ -0,0 +1,80 @@
import io
from django.conf import settings
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 mdb.pxe import render_pxelinux_cfg
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
from mdb.models import Host, OperatingSystem
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_render_pxeconfig(self):
host = Host.objects.first()
interface = host.interface_set.exclude(ip4address__isnull=True).first()
config = render_pxelinux_cfg(interface, host)
self.assertIn(settings.MDB_PXE_INITRD, config)
self.assertIn(settings.MDB_PXE_KERNEL, config)
self.assertIn(settings.MDB_PXE_PRESEED_URL, config)
def test_render_pxeconfig_with_os_pxe_config(self):
_os = OperatingSystem.objects.get(pk=2) # OS with all pxe vars set
Host.objects.filter(pk=Host.objects.first().pk).update(operating_system=_os)
host = Host.objects.first()
interface = host.interface_set.exclude(ip4address__isnull=True).first()
config = render_pxelinux_cfg(interface, host)
self.assertIn(_os.pxe_initrd, config)
self.assertIn(_os.pxe_kernel, config)
self.assertIn(_os.pxe_preseed_config_url, config)
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
+29 -11
View File
@@ -1,16 +1,34 @@
import datetime import datetime
import re
def format_domain_serial_and_add_one(serial): def format_domain_serial_and_add_one(serial):
"""Return the serial after `serial`, in the YYYYMMDDnn form where it can be. today = datetime.datetime.now()
res = re.findall("^%4d%02d%02d(\d\d)$" % (
today.year, today.month, today.day), str(serial), re.DOTALL)
The serial must always increase, or named and dhcpd ignore the change. Today's first if len(res) == 0:
serial is used while it is higher. Otherwise the serial goes up by one: past nn = 99 """ This probably means that the serial is malformed
it carries into the date (2026110299 -> 2026110300), and a serial already ahead of or the date is wrong. We assume that if the date is wrong,
today, from that carry or a dynamic update, keeps counting from where it is. it is in the past. Just create a new serial starting from 1."""
""" return "%4d%02d%02d%02d" % \
today_first = int(datetime.date.today().strftime("%Y%m%d")) * 100 + 1 (today.year, today.month, today.day, 1)
try: elif len(res) == 1:
return max(int(serial) + 1, today_first) """ The serial contains todays date, just update it. """
except TypeError, ValueError: try:
return today_first number = int(res[0])
except ValueError:
number = 1
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)
+9 -8
View File
@@ -1,14 +1,15 @@
from django.core.validators import RegexValidator
import re import re
from django.core.validators import RegexValidator
# http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names # http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
hostname_re = re.compile(r"^(?!-)[-a-z0-9]+(?<!-)$", re.IGNORECASE) hostname_re = re.compile(r'^(?!-)[-a-z0-9]+(?<!-)$', re.IGNORECASE)
validate_hostname = RegexValidator( validate_hostname = RegexValidator(
regex=hostname_re, message="Enter a valid hostname", code="invalid" 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) 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, message="Enter a valid MAC address", code="invalid" regex=macaddr_re,
) message='Enter a valid MAC address',
code='invalid')
+134 -93
View File
@@ -1,144 +1,185 @@
import os import os
import raven
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) BASE_DIR = os.path.dirname(os.path.dirname(__file__))
# Off unless DJANGO_DEBUG says otherwise; mise.toml turns it on for development. DEBUG = True
DEBUG = os.getenv("DJANGO_DEBUG", "").lower() in ("1", "true", "yes") TEMPLATE_DEBUG = DEBUG
ADMINS = ("sysadmin@example.com",) ADMINS = (
('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
DEFAULT_AUTO_FIELD = "django.db.models.AutoField" MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
ADMIN_MEDIA_PREFIX = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, "media") STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
MEDIA_URL = "/media/" STATIC_URL = '/static/'
# Static
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
STATIC_URL = "/static/"
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
"django.contrib.staticfiles.finders.FileSystemFinder", 'django.contrib.staticfiles.finders.FileSystemFinder',
"django.contrib.staticfiles.finders.AppDirectoriesFinder", 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
) )
# For development only: the repository is public. Production sets its own (MDB_SETTINGS). # Make this unique, and don't share it with anybody.
SECRET_KEY = "@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl" SECRET_KEY = '@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl'
MIDDLEWARE = ( MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", 'django.middleware.common.CommonMiddleware',
# Serves STATIC_ROOT, so production needs nothing in front but a proxy. 'django.contrib.sessions.middleware.SessionMiddleware',
"whitenoise.middleware.WhiteNoiseMiddleware", 'django.middleware.csrf.CsrfViewMiddleware',
"django.middleware.common.CommonMiddleware", 'django.contrib.auth.middleware.AuthenticationMiddleware',
"django.contrib.sessions.middleware.SessionMiddleware", 'django.contrib.messages.middleware.MessageMiddleware',
"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'
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", 'BACKEND': 'django.template.backends.django.DjangoTemplates',
"DIRS": [], 'DIRS': [],
"APP_DIRS": True, 'APP_DIRS': True,
"OPTIONS": { 'OPTIONS': {
"context_processors": [ 'context_processors': [
"django.contrib.auth.context_processors.auth", 'django.template.context_processors.debug',
"django.template.context_processors.debug", 'django.template.context_processors.request',
"django.template.context_processors.request", 'django.template.context_processors.request',
"django.template.context_processors.i18n", 'django.contrib.auth.context_processors.auth',
"django.template.context_processors.media", 'django.contrib.messages.context_processors.messages',
"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',
"django.contrib.admin", 'suit',
"rest_framework", 'django.contrib.admin',
"rest_framework.authtoken", 'rest_framework',
"django_extensions", 'rest_framework.authtoken',
"mdb", 'django_extensions',
'raven.contrib.django.raven_compat',
'mdb',
) )
EMAIL_SUBJECT_PREFIX = "[MDB] " EMAIL_SUBJECT_PREFIX = '[MDB] '
# Logging # Logging
LOGGING = { LOGGING = {
"version": 1, 'version': 1,
"disable_existing_loggers": True, 'disable_existing_loggers': True,
"formatters": { 'formatters': {
"verbose": { 'verbose': {
"format": "%(levelname)s %(asctime)s %(module)s " 'format': '%(levelname)s %(asctime)s %(module)s '
"%(process)d %(thread)d %(message)s" '%(process)d %(thread)d %(message)s'
}, },
}, },
"handlers": { 'handlers': {
"console": { 'sentry': {
"level": "DEBUG", 'level': 'WARNING', # To capture more than ERROR, change to WARNING, INFO, etc.
"class": "logging.StreamHandler", 'class': 'raven.contrib.django.raven_compat.handlers.SentryHandler',
"formatter": "verbose", },
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'verbose'
} }
}, },
"loggers": { 'loggers': {
"root": { 'root': {
"level": "WARNING", 'level': 'WARNING',
"handlers": ["console"], 'handlers': ['sentry'],
}, },
"django.db.backends": { 'django.db.backends': {
"level": "ERROR", 'level': 'ERROR',
"handlers": ["console"], 'handlers': ['console'],
"propagate": False, 'propagate': False,
},
'raven': {
'level': 'WARNING',
'handlers': ['console'],
'propagate': False,
},
'sentry.errors': {
'level': 'DEBUG',
'handlers': ['console'],
'propagate': False,
}, },
}, },
} }
# django-suit configuration
SUIT_CONFIG = {
# header
'ADMIN_NAME': 'MDB',
'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')
},
),
# misc
'LIST_PER_PAGE': 35
}
# REST Framework # REST Framework
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
'PAGINATE_BY': 10
} }
# PXE
MDB_PXE_TFTP_ROOT = '/var/lib/tftpboot/pxelinux/pxelinux.cfg/'
MDB_PXE_KERNEL = 'ubuntu/14_04/amd64/alternate/linux'
MDB_PXE_INITRD = 'ubuntu/14_04/amd64/alternate/initrd.gz'
MDB_PXE_PRESEED_URL = 'http://158.36.190.194/ubuntu/preseed_1404.cfg'
# Sentry # Sentry
SENTRY_DSN = os.getenv("SENTRY_DSN") RAVEN_CONFIG = {
SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONMENT", "dev") 'dsn': os.getenv('RAVEN_DSN'),
'release': raven.fetch_git_sha(BASE_DIR)
}
ADMIN_SITE_NAME = "MDB" try:
from .local_settings import *
# Production's settings live outside the code, in the file MDB_SETTINGS names; the mdb role except ImportError:
# in edb-infra writes it. An error in it stops MDB rather than leaving the defaults above. pass
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:
from .local_settings import * # noqa: F403
except ModuleNotFoundError as e:
if e.name != "mdbsite.local_settings":
raise
+4 -9
View File
@@ -1,20 +1,15 @@
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.urls import re_path, 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 = [
path("", RedirectView.as_view(url="admin/", permanent=True)), re_path(r'^$', RedirectView.as_view(url='admin/', permanent=True)),
path("admin/", admin.site.urls), path('admin/', admin.site.urls),
path( path('api/validate-host-secret/', HostPXEValidate.as_view(), name='api-validate-host-secret')
"api/validate-host-secret/",
HostPXEValidate.as_view(),
name="api-validate-host-secret",
),
] ]
urlpatterns += staticfiles_urlpatterns() urlpatterns += staticfiles_urlpatterns()
+1 -2
View File
@@ -1,6 +1,5 @@
import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdbsite.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdbsite.settings")
-8
View File
@@ -1,8 +0,0 @@
[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
@@ -1,32 +0,0 @@
# 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
@@ -1,59 +0,0 @@
[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"
+8
View File
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,8 @@
#!/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
@@ -0,0 +1,105 @@
#!/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
@@ -0,0 +1,156 @@
#!/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
@@ -0,0 +1,151 @@
#!/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
@@ -0,0 +1,567 @@
/*!
* 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
@@ -0,0 +1,3 @@
.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
@@ -0,0 +1,610 @@
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.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+1722
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
!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.

After

Width:  |  Height:  |  Size: 923 B

+4
View File
@@ -0,0 +1,4 @@
/*! 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
@@ -1,55 +0,0 @@
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
@@ -1,103 +0,0 @@
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
@@ -1,121 +0,0 @@
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
@@ -1,135 +0,0 @@
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
@@ -1,82 +0,0 @@
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
@@ -1,64 +0,0 @@
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
@@ -1,142 +0,0 @@
"""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
@@ -1,37 +0,0 @@
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
@@ -1,90 +0,0 @@
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
@@ -1,57 +0,0 @@
"""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
@@ -1,86 +0,0 @@
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
@@ -1,32 +0,0 @@
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
@@ -1,373 +0,0 @@
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
@@ -1,369 +0,0 @@
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
@@ -1,415 +0,0 @@
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" },
]