Compare commits

..
Author SHA1 Message Date
nikolark acd082b2f8 Starts zone file template 2015-10-06 23:08:55 +02:00
77 changed files with 7628 additions and 3225 deletions

No files matched your search

-38
View File
@@ -1,38 +0,0 @@
name: checks
# Checks only, nothing here deploys. deploy.yaml calls it before deploying.
on:
push:
pull_request:
workflow_call:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
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
-33
View File
@@ -1,33 +0,0 @@
name: deploy
on:
push:
branches: [main]
jobs:
checks:
if: vars.DEPLOY_SEGA == 'true'
uses: ./.gitea/workflows/checks.yaml
prek:
if: vars.DEPLOY_SEGA == 'true'
uses: ./.gitea/workflows/prek.yaml
# 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: vars.DEPLOY_SEGA == 'true'
runs-on: ubuntu-latest
needs:
- checks
- 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 }}"
-14
View File
@@ -1,14 +0,0 @@
name: prek
# The hooks in prek.toml. deploy.yaml calls it before deploying.
on:
push:
pull_request:
workflow_call:
jobs:
prek:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: j178/prek-action@v1
+11 -36
View File
@@ -1,46 +1,21 @@
A Django app for managing computer equipment.
## Installation
Install system dependencies:
sudo apt install python-dev libldap2-dev libsasl2-dev bind9-utils
Use [mise](https://mise.jdx.dev/) to manage Python and uv:
mise trust
mise install
Install Python dependencies with uv:
uv sync
Run migrations and start the development server:
uv run python manage.py migrate
uv run python manage.py runserver
`mise.toml` sets `DJANGO_DEBUG=1`; without it `DEBUG` is off. Local overrides go in
`mdbsite/local_settings.py`.
## Production
MDB runs on sega under edb-infra's `mdb` role (`roles/mdb.md` there): gunicorn from a
release directory per commit, its settings in the file `MDB_SETTINGS` names
(`/etc/mdb/settings.py`), which `mdbsite/settings.py` executes last. A push to `main` that
passes the checks deploys itself while the repository variable `DEPLOY_SEGA` is `true`
(`.gitea/workflows/deploy.yaml`). The DNS and DHCP sync runs as root on sega:
`sudo mdb-sync`.
sudo apt install libmysqlclient-dev python-dev libldap2-dev libsasl2-dev bind9utils
virtualenv venv
. venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver
## Development tasks
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
- Creates DHCPD configuration files
- Creates DNS zone files for BIND
- Creates PXE config files (with random secret for use with Puppet autosign)
- Partial IPv6 support
* Creates DHCPD configuration files
* Creates DNS zone files for BIND
* Creates PXE config files (with random secret for use with Puppet autosign)
* Partial IPv6 support
-1
View File
@@ -5,5 +5,4 @@ import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdbsite.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
+1
View File
@@ -0,0 +1 @@
default_app_config = 'mdb.apps.MdbAppConfig'
+50 -235
View File
@@ -1,45 +1,8 @@
import ipaddress
from django.conf import settings
from django.contrib import admin, messages
from django.db import models
from django.db.models import Count
from django.forms import NumberInput
from mdb.forms import InterfaceForm, SerialNumberInput
from mdb.models import (
DhcpConfig,
DhcpCustomField,
DhcpOption,
Domain,
DomainAAAARecord,
DomainARecord,
DomainCnameRecord,
DomainSrvRecord,
DomainTxtRecord,
Host,
HostType,
Interface,
Ip4Address,
Ip4Subnet,
Ip6Address,
Ip6Subnet,
MailExchange,
Nameserver,
OperatingSystem,
)
admin.site.site_header = settings.ADMIN_SITE_NAME
class SerialFieldAdminMixin:
"""Render the ``domain_serial`` field with the wide serial widget + button."""
def formfield_for_dbfield(self, db_field, request, **kwargs):
formfield = super().formfield_for_dbfield(db_field, request, **kwargs)
if db_field.name == "domain_serial":
formfield.widget = SerialNumberInput()
return formfield
from mdb.models import Ip6Address, Interface, Ip4Address, DhcpOption, DhcpCustomField, DomainSrvRecord, DomainTxtRecord, \
DomainCnameRecord, DomainARecord, Domain, Host, Ip4Subnet, Ip6Subnet, Nameserver, MailExchange, OperatingSystem, \
HostType, DhcpConfig, DomainAAAARecord
class Ip6AddressInline(admin.TabularInline):
@@ -51,119 +14,64 @@ class InterfaceInline(admin.TabularInline):
inlines = [Ip6AddressInline]
model = Interface
extra = 0
form = InterfaceForm
def get_queryset(self, request):
return super().get_queryset(request).select_related()
return super(InterfaceInline, self).get_queryset(request).select_related()
@admin.register(Host)
class HostAdmin(admin.ModelAdmin):
ordering = ("hostname",)
ordering = ('hostname',)
inlines = [InterfaceInline]
list_display = [
"hostname",
"owner",
"host_type",
"location",
"mac_addresses",
"ip_addresses",
"in_domain",
"pxe_installable",
"ipv6_enabled",
"allow_email",
]
list_filter = ["host_type", "owner", "location", "pxe_installable", "allow_email"]
readonly_fields = [
"kerberos_principal_name",
"kerberos_principal_created_date",
"kerberos_principal_created",
]
search_fields = [
"hostname",
"location",
"interface__macaddr",
"interface__ip4address__address",
]
list_display = ['hostname', 'owner', 'host_type', 'location', 'mac_addresses', 'ip_addresses', 'in_domain',
'pxe_installable', 'ipv6_enabled']
list_filter = ['host_type', 'owner', 'location', 'pxe_installable']
readonly_fields = ['kerberos_principal_name', 'kerberos_principal_created_date',
'kerberos_principal_created']
search_fields = ['hostname', 'location', 'interface__macaddr', 'interface__ip4address__address']
fieldsets = (
("Owner Information", {"fields": ("owner", "location", "description")}),
(
"Hardware and Software Information",
{
"fields": (
("hostname", "host_type"),
("pxe_key", "pxe_installable"),
("brand", "model"),
"serial_number",
("operating_system", "virtual"),
"allow_email",
)
},
),
('Owner Information', {
'fields': ('owner', 'location', 'description')
}),
('Hardware and Software Information', {
'fields': (('hostname', 'host_type'), ('pxe_key', 'pxe_installable'), ('brand', 'model'), 'serial_number', ('operating_system', 'virtual'))
}),
# FIXME: Not in use
# ('Domain and Kerberos Information', {
# 'description': 'If this host is a member of the LDAP domain, you need to tick the request kerberos principal checkbox. A principal will then be created for the host.',
# 'classes': ['collapse'],
# 'fields': ('request_kerberos_principal', 'kerberos_principal_created',
# ('kerberos_principal_name', 'kerberos_principal_created_date'))
# }),
)
actions = ["set_installable"]
actions = ['set_installable']
def mac_addresses(self, host):
addresses = host.interface_set.filter(macaddr__isnull=False).values_list(
"macaddr", flat=True
)
return ", ".join(addresses)
def _get_host_warning_message(self, host, ifs):
ifs_str = ', '.join([_if.name for _if in ifs])
return 'Host {} might not be installable since interface(s) {} has no IP set.'.format(host, ifs_str)
def ip_addresses(self, host):
addresses = host.interface_set.filter(ip4address__isnull=False).values_list(
"ip4address__address", flat=True
)
return ", ".join(addresses)
@admin.display(description="in domains")
def in_domain(self, host):
domains = host.interface_set.values_list("domain__domain_name", flat=True)
return ",".join(domains)
@admin.display(boolean=True)
def ipv6_enabled(self, host):
ipv6_ifs = host.interface_set.annotate(num_ipv6=Count("ip6address")).filter(
num_ipv6__gt=0
)
return ipv6_ifs.exists()
# FIXME: custom filter to sort custom field
@staticmethod
def _get_host_warning_message(host, ifs):
ifs_str = ", ".join([_if.name for _if in ifs])
return f"Host {host} might not be installable since interface(s) {ifs_str} has no IP set."
@admin.action(description="Mark selected hosts as PXE installable")
def set_installable(self, request, queryset):
invalid_ifs_per_host = []
for obj in queryset:
invalid_ifs = obj.interface_set.filter(ip4address__isnull=True)
if invalid_ifs.exists():
invalid_ifs_per_host.append(
self._get_host_warning_message(obj, invalid_ifs)
)
invalid_ifs_per_host.append(self._get_host_warning_message(obj, invalid_ifs))
obj.pxe_installable = True
obj.save() # triggers post save signal
if invalid_ifs_per_host:
self.message_user(
request, message="\n".join(invalid_ifs_per_host), level=messages.WARNING
)
self.message_user(request, message='\n'.join(invalid_ifs_per_host), level=messages.WARNING)
set_installable.short_description = "Mark selected hosts as PXE installable"
def get_queryset(self, request):
return super().get_queryset(request).select_related()
return super(HostAdmin, self).get_queryset(request).select_related()
class Ip4AddressInline(admin.TabularInline):
model = Ip4Address
extra = 0
readonly_fields = ["address", "assigned_to_host"]
@admin.display(description="Assigned to Host")
def assigned_to_host(self, addr):
return addr.interface.host
readonly_fields = ['address', 'assigned_to_host']
class DhcpOptionInline(admin.TabularInline):
@@ -176,76 +84,13 @@ class DhcpCustomFieldInline(admin.TabularInline):
extra = 0
@admin.register(Ip4Subnet)
class Ip4SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
list_display = [
"name",
"network",
"netmask",
"domain_name",
"num_addresses",
"broadcast_address",
"first_address",
"last_address",
]
readonly_fields = ["domain_name"]
class SubnetAdmin(admin.ModelAdmin):
list_display = ['name', 'network', 'netmask', 'num_addresses', 'broadcast_address', 'first_address', 'last_address']
inlines = [DhcpOptionInline, DhcpCustomFieldInline, Ip4AddressInline]
@admin.display(description="# addresses")
def num_addresses(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return subnet.num_addresses
@admin.display(description="broadcast")
def broadcast_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return subnet.broadcast_address
@admin.display(description="first address")
def first_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return next(subnet.hosts())
@admin.display(description="last address")
def last_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return list(subnet.hosts())[-1]
def get_queryset(self, request):
# TODO: optimize
return super().get_queryset(request)
@admin.register(Ip6Subnet)
class Ip6SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
list_display = [
"name",
"network",
"netmask",
"domain_name",
"num_addresses",
"first_address",
"last_address",
]
readonly_fields = ["domain_name"]
def _network(self, ip6subnet):
return ipaddress.IPv6Network(
f"{ip6subnet.network}::/{ip6subnet.netmask:d}", strict=False
)
@admin.display(description="# addresses")
def num_addresses(self, ip6subnet):
return self._network(ip6subnet).num_addresses
@admin.display(description="first address")
def first_address(self, ip6subnet):
return self._network(ip6subnet).network_address
@admin.display(description="last address")
def last_address(self, ip6subnet):
# subnet[-1] is O(1); never materialize hosts() for a /64.
return self._network(ip6subnet)[-1]
return super(SubnetAdmin, self).get_queryset(request)
class DomainSrvRecordInline(admin.TabularInline):
@@ -273,59 +118,29 @@ class DomainAAAARecordInline(admin.TabularInline):
extra = 0
@admin.register(Domain)
class DomainAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
inlines = [
DomainSrvRecordInline,
DomainTxtRecordInline,
DomainARecordInline,
DomainAAAARecordInline,
DomainCnameRecordInline,
]
list_display = [
"domain_name",
"domain_soa",
"domain_admin",
"num_records",
"domain_ipaddr",
"domain_ip6addr",
]
search_fields = ["domain_name"]
formfield_overrides = {
models.IntegerField: {"widget": NumberInput(attrs={"style": "width: 10em"})},
}
@admin.display(description="Num Records")
def num_records(self, domain):
host_a_records = Host.objects.filter(interface__domain=domain).count()
size = {
"CNAME": domain.domaincnamerecord_set.count(),
"SRV": domain.domainsrvrecord_set.count(),
"TXT": domain.domaintxtrecord_set.count(),
"A": domain.domainarecord_set.count() + host_a_records,
}
return ", ".join([f"{k}: {v}" for k, v in size.items()])
class DomainAdmin(admin.ModelAdmin):
inlines = [DomainSrvRecordInline, DomainTxtRecordInline, DomainARecordInline, DomainAAAARecordInline,
DomainCnameRecordInline]
list_display = ['domain_name', 'domain_soa', 'domain_admin', 'num_records', 'domain_ipaddr', 'domain_ip6addr']
search_fields = ['domain_name']
@admin.register(HostType)
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):
list_display = [
"name",
"version",
"arch",
"pxe_kernel",
"pxe_initrd",
"pxe_preseed_config_url",
]
list_display = ['name', 'version', 'arch']
admin.site.register(Domain, DomainAdmin)
admin.site.register(Host, HostAdmin)
admin.site.register(Ip4Subnet, SubnetAdmin)
admin.site.register(Ip6Subnet)
admin.site.register(Ip6Address)
admin.site.register(Nameserver)
admin.site.register(MailExchange)
admin.site.register(OperatingSystem, OperatingSystemAdmin)
admin.site.register(HostType, HostTypeAdmin)
admin.site.register(DhcpConfig)
admin.site.register(DhcpOption)
+11 -23
View File
@@ -1,8 +1,7 @@
from mdb.models import Host
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from mdb.models import Host
class HostPXEValidateSerializer(serializers.Serializer):
certname = serializers.CharField(required=True)
@@ -10,15 +9,13 @@ class HostPXEValidateSerializer(serializers.Serializer):
host = None
def _get_host_by_fqdn(self, attrs):
fqdn = attrs["certname"]
host_name = fqdn.split(".")[0]
domain = ".".join(fqdn.split(".")[1:])
fqdn = attrs['certname']
host_name = fqdn.split('.')[0]
domain = '.'.join(fqdn.split('.')[1:])
try:
return Host.objects.get(
hostname=host_name, interface__domain__domain_name=domain
)
except Host.DoesNotExist, Host.MultipleObjectsReturned:
return Host.objects.get(hostname=host_name, interface__domain__domain_name=domain)
except (Host.DoesNotExist, Host.MultipleObjectsReturned):
return None
def validate(self, attrs):
@@ -26,24 +23,16 @@ class HostPXEValidateSerializer(serializers.Serializer):
host = self._get_host_by_fqdn(attrs)
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:
raise ValidationError(
"Host '{}' is marked as not installable via PXE.".format(
attrs["certname"]
)
)
raise ValidationError('Host \'{}\' is marked as not installable via PXE.'.format(attrs['certname']))
if not host.pxe_key:
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"]:
raise ValidationError(
"Supplied pxe_key '{}' does not match host '{}'.".format(
attrs["pxe_key"], attrs["certname"]
)
)
if host.pxe_key != attrs['pxe_key']:
raise ValidationError('Supplied pxe_key does not match host \'{}\'.'.format(attrs['certname']))
self.host = host
@@ -52,7 +41,6 @@ class HostPXEValidateSerializer(serializers.Serializer):
def create(self, validated_data):
# Make requests kind of idempotent by only allowing 1 request / installation
self.host.pxe_installable = False
self.host.pxe_key = ""
self.host.save()
return self.host
+2 -2
View File
@@ -15,6 +15,6 @@ class HostPXEValidate(APIView):
serializer.is_valid(raise_exception=True)
serializer.save() # sets Host.pxe_installable=False
host = serializer.save() # sets Host.pxe_installable=False
return Response({"valid_pxe_key": True})
return Response({'valid_pxe_key': True})
+3 -3
View File
@@ -2,9 +2,9 @@ from django.apps import AppConfig
class MdbAppConfig(AppConfig):
name = "mdb"
verbose_name = "Machine Database"
name = 'mdb'
verbose_name = 'Machine Database'
def ready(self):
# all models are loaded, now attach signals
import mdb.signals # noqa
import mdb.signals
+8 -20
View File
@@ -34,7 +34,7 @@
"domain_active_serial": 0,
"domain_ip6addr": "::1",
"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_admin": "sysadmin@neuf.no",
"domain_minimum_ttl": 86400,
@@ -51,7 +51,7 @@
{
"fields": {
"domain": 1,
"created_date": "2015-10-04T17:13:46.810Z",
"created_date": "2015-10-04T17:13:46.810",
"name": "www",
"target": "edb.technology."
},
@@ -61,7 +61,7 @@
{
"fields": {
"domain": 1,
"created_date": "2015-10-04T17:13:46.807Z",
"created_date": "2015-10-04T17:13:46.807",
"name": "test",
"target": "127.0.0.1"
},
@@ -71,7 +71,7 @@
{
"fields": {
"domain": 1,
"created_date": "2015-10-04T17:13:46.809Z",
"created_date": "2015-10-04T17:13:46.809",
"name": "test",
"target": "::1"
},
@@ -103,7 +103,7 @@
"netmask": 64,
"domain_active_serial": 0,
"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_admin": "sysadmin@neuf.no",
"domain_minimum_ttl": 86400,
@@ -132,7 +132,7 @@
"netmask": "255.255.255.0",
"domain_active_serial": 0,
"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_admin": "sysadmin@neuf.no",
"domain_minimum_ttl": 86400,
@@ -2738,18 +2738,6 @@
"model": "mdb.operatingsystem",
"pk": 1
},
{
"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",
"name": "Ubuntu",
"version": "16.04-beta-final"
},
"model": "mdb.operatingsystem",
"pk": 2
},
{
"fields": {
"kerberos_principal_name": "",
@@ -2763,7 +2751,7 @@
"pxe_installable": true,
"kerberos_principal_created_date": null,
"location": "kak",
"created_date": "2015-10-03T13:23:37.061Z",
"created_date": "2015-10-03T13:23:37.061",
"owner": "edb",
"serial_number": "12345678",
"model": "OptiPlex 760",
@@ -2782,7 +2770,7 @@
"host": 1,
"macaddr": "f0:de:f1:58:be:1a",
"ip4address": 2,
"created_date": "2015-10-03T13:23:37.063Z"
"created_date": "2015-10-03T13:23:37.063"
},
"model": "mdb.interface",
"pk": 1
-77
View File
@@ -1,77 +0,0 @@
from django import forms
from django.core.exceptions import ValidationError
from django.forms import ModelChoiceField, NumberInput
from django.utils.safestring import mark_safe
from mdb.models import Interface, Ip4Address
class SerialNumberInput(NumberInput):
"""Number input for DNS SOA serials (YYYYMMDDXX).
Renders wide enough to show the full 10-digit serial and adds a button
that prefills the field with today's date plus the next available XX
counter (incrementing if the field already holds today's serial).
"""
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');
var current = (input.value || '').trim();
var xx = 1;
if (current.length === 10 && current.slice(0, 8) === today) {
xx = Math.min(parseInt(current.slice(8), 10) + 1, 99);
}
input.value = today + String(xx).padStart(2, '0');
};
}
</script>
""".replace("__ID__", field_id)
return mark_safe(field + extra)
class Ip4ModelChoiceField(ModelChoiceField):
def label_from_instance(self, obj):
if not hasattr(obj, "interface"):
return obj.address
return f"{obj.address} ({obj.interface.host.hostname})"
class InterfaceForm(forms.ModelForm):
model = Interface
def clean(self):
macaddr = self.cleaned_data["macaddr"]
ip4address = self.cleaned_data["ip4address"]
for interface in Interface.objects.filter(macaddr=macaddr):
subnet = interface.ip4address.subnet
if subnet == ip4address.subnet and interface != self.instance:
raise ValidationError("Macaddr already in this subnet")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Join Ip4Address with tables interface and host,
# before printing the select labels that walk those two relations.
queryset = Ip4Address.objects.select_related("interface", "interface__host")
self.fields["ip4address"] = Ip4ModelChoiceField(queryset=queryset)
+106
View File
@@ -0,0 +1,106 @@
from __future__ import with_statement
import os
import sys
from difflib import context_diff
from django.core.management.base import BaseCommand
from django.core.mail import mail_admins
from optparse import make_option
try:
from subprocess import getstatusoutput
except ImportError:
from commands import getstatusoutput
from mdb.models import DhcpConfig
class Command(BaseCommand):
args = '--output <path> [--force] [--restart]'
help = 'Generates the DHCP configuration'
option_list = BaseCommand.option_list + (
make_option('--output',
action='store',
dest='output',
default=None,
help='Path to dhcpd.conf, defaults to stdout'),
make_option('--force',
action='store_true',
dest='force',
default=False,
help='Write configuration even if serial is unchanged.'),
make_option('--restart',
action='store_true',
dest='restart',
default=False,
help='Restart dhcp3-server if serial is changed'),
)
def handle(self, *args, **options):
output = options['output'] if options['output'] else sys.stdout
force = options['force']
restart = options['restart']
restart_command = "service isc-dhcp-server restart"
config = DhcpConfig.objects.filter(name="default").get()
# do we need to do anything?
if config.serial == config.active_serial and not force:
print("Nothing to do, serial is the same: %s" % config.serial)
sys.exit(0)
# write to stdout
if output == sys.stdout:
print(config.dhcpd_configuration())
print('Not writing to file, serial not updated')
return
# read current file contents
try:
with open(output, 'r') as f:
old = f.read()
except IOError:
old = ''
# write to file
new = config.dhcpd_configuration()
with open(output, 'w') as f:
f.write(new)
# calculate diff
diff = "\n".join(context_diff(
a=old.splitlines(),
b=new.splitlines(),
fromfile=config.active_serial,
tofile=config.serial,
lineterm=''))
print(diff)
# update serial
old_serial = config.active_serial
config.active_serial = config.serial
config.save()
# restart the dhcp server?
if restart:
status, output = getstatusoutput(restart_command)
if status != 0:
message = (
"Failed to restart dhcp3-server, please inspect...\n\n" +
"%s@%s# %s" % (os.getlogin(), os.uname()[1], restart_command) +
"\n%s\n\n%s" % (output, diff))
mail_admins(
subject="FAIL: dhcpd %s -> %s" % (old_serial, config.active_serial),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(1)
else:
message = (
"Updated dhcpd configuration to serial %d\n\n" % config.active_serial +
"%s" % diff)
mail_admins(
subject="Success: dhcpd %s -> %s" % (old_serial, config.active_serial),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(0)
-82
View File
@@ -1,82 +0,0 @@
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"{os.getlogin()}@{os.uname()[1]}$ {service.restart_command}\n{output}"
)
discord.notify(
subject=f"⚠️ FAIL: dhcpd {old_serial} -> {config.active_serial}",
message=message,
preformatted=diff or None,
)
raise SystemExit(1)
self.stdout.write("ok\n")
message = f"Updated dhcpd configuration to serial {config.active_serial:d}"
discord.notify(
subject=f"✅ Success: dhcpd {old_serial} -> {config.active_serial}",
message=message,
preformatted=diff or None,
)
+175
View File
@@ -0,0 +1,175 @@
from __future__ import with_statement
import os
import sys
from itertools import chain
from difflib import context_diff
from django.core.management.base import BaseCommand
from django.core.mail import mail_admins
from optparse import make_option
try:
from subprocess import getstatusoutput
except ImportError:
from commands import getstatusoutput
from mdb.models import Domain, Ip4Subnet, Ip6Subnet
class Command(BaseCommand):
args = '[--force] [--debug]'
help = 'Generates the bind configuration'
option_list = BaseCommand.option_list + (
make_option('--debug',
action='store_true',
dest='debug',
default=False,
help='Debug mode. Writes to /tmp and does not reload.'),
make_option('--force',
action='store_true',
dest='force',
default=False,
help='Write zone files even if serial is unchanged.'),
)
bind_bin = "/etc/init.d/bind9"
reload_command = "%s reload" % bind_bin
checkzone_bin = "/usr/sbin/named-checkzone"
checkzone_dir = "/tmp/mdb-checkzone"
debug = False
debug_dir = '/tmp/mdb-debug'
changes = {}
def handle(self, *args, **options):
self.debug = debug = options['debug']
force = options['force']
# do the binaries exist?
for f in (self.checkzone_bin, self.bind_bin):
if not os.path.isfile(f):
print("ERROR: no such file %s, exiting..." % f)
if not debug:
sys.exit(1)
# create directories if needed
os.path.isdir(self.checkzone_dir) or os.mkdir(self.checkzone_dir)
debug and (os.path.isdir(self.debug_dir) or os.mkdir(self.debug_dir))
# update zones with new serials
for zone in chain(Domain.objects.all(),
Ip4Subnet.objects.all(),
Ip6Subnet.objects.all()):
if zone.domain_serial == zone.domain_active_serial or force:
continue
if self.update_zone(zone):
self.changes[zone.domain_name]['success'] = True
# join errors, diffs and zone names
errors = "\n\n\n".join(
[v['errors'] for k, v in self.changes.items() if v['errors']])
diffs = "\n\n\n".join(
[v['diff'] for k, v in self.changes.items() if v['diff']])
zones = ", ".join(self.changes.keys())
# reload and send email with changes
if self.changes and not debug:
sys.stdout.write("reloading bind... ")
status, output = getstatusoutput(self.reload_command)
if status != 0:
sys.stdout.write("fail\n")
message = (
"Failed to reload bind, please inspect...\n\n" +
"%s@%s# %s" % (
os.getlogin(), os.uname()[1], self.reload_command) +
"\n%s\n\n\n%s\n\n\n%s" % (output, errors, diffs))
mail_admins(
subject="FAIL: bind (%s changed: %s)" % (len(self.changes), zones),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(1)
else:
sys.stdout.write("ok\n")
message = (
"Successfully updated bind zone files\n\n" +
"%s\n\n%s" % (errors, diffs))
mail_admins(
subject="Success: bind (%s changed: %s)" % (len(self.changes), zones),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(0)
def update_zone(self, zone):
zonetype = getattr(zone._meta, 'verbose_name', None) \
or zone._meta.model_name
self.changes[zone.domain_name] = {
'old_serial': zone.domain_active_serial,
'new_serial': zone.domain_serial,
'success': False,
'errors': None,
'diff': None,
}
print("updating %s %s [%d -> %d]" % (
zonetype,
zone.domain_name,
zone.domain_active_serial,
zone.domain_serial))
# generate zone file contents
new = zone.zone_file_contents()
# read current file contents
try:
with open(zone.domain_filename, 'r') as f:
old = f.read()
except IOError:
old = ''
# calculate diff
diff = "\n".join(context_diff(
a=old.splitlines(),
b=new.splitlines(),
fromfile=str(zone.domain_active_serial) + '-' + zone.domain_name,
tofile=str(zone.domain_serial) + '-' + zone.domain_name,
lineterm=''))
self.changes[zone.domain_name]['diff'] = diff
# validate with zonecheck
sys.stdout.write("\t- validating... ")
status, output = self.check_zone(zone, new)
if status != 0:
sys.stdout.write("fail\n")
self.changes[zone.domain_name]['errors'] = output
return False
sys.stdout.write("ok\n")
# write to zone file
sys.stdout.write("\t- writing zone file... ")
if not self.debug:
zonefile = zone.domain_filename
else:
zonefile = self.debug_dir + zone.domain_filename
path = "/".join(zonefile.split('/')[:-1])
os.path.isdir(path) or os.makedirs(path)
with open(zonefile, 'w') as f:
f.write(new)
# update serial
if not self.debug:
zone.domain_active_serial = zone.domain_serial
zone.save()
sys.stdout.write("ok\n")
self.changes[zone.domain_name]['success'] = True
return True
def check_zone(self, zone, contents):
zonefile = self.checkzone_dir + '/' + zone.domain_name
with open(zonefile, 'w') as f:
f.write(contents)
return getstatusoutput("%s %s %s" % (
self.checkzone_bin, zone.domain_name, zonefile))
-131
View File
@@ -1,131 +0,0 @@
import os
from django.core.management.base import BaseCommand
from mdb.services import discord
from mdb.services.zone_sync import ZoneSyncService
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 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
self.freeze(service, zones)
try:
for zone in zones:
service.sync_zone(zone)
except BaseException:
service.reload()
raise
self.reload_and_notify(service, service.changes)
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.run_commands([service.thaw_command])
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{os.getlogin()}@{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)
+18 -16
View File
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import re
import django.core.validators
@@ -79,7 +81,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=256)),
('target', models.CharField(max_length=256)),
('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
('domain', models.ForeignKey(to='mdb.Domain')),
],
options={
},
@@ -92,7 +94,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=256)),
('target', models.CharField(max_length=256)),
('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
('domain', models.ForeignKey(to='mdb.Domain')),
],
options={
},
@@ -110,7 +112,7 @@ class Migration(migrations.Migration):
('port', models.IntegerField()),
('target', models.CharField(max_length=256)),
('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
('domain', models.ForeignKey(to='mdb.Domain')),
],
options={
},
@@ -123,7 +125,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=256)),
('target', models.CharField(max_length=256)),
('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
('domain', models.ForeignKey(to='mdb.Domain')),
],
options={
},
@@ -172,8 +174,8 @@ class Migration(migrations.Migration):
('pxe_filename', models.CharField(max_length=64, blank=True)),
('dhcp_client', models.BooleanField(default=False)),
('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
('host', models.ForeignKey(to='mdb.Host', on_delete=models.CASCADE)),
('domain', models.ForeignKey(to='mdb.Domain')),
('host', models.ForeignKey(to='mdb.Host')),
],
options={
},
@@ -215,7 +217,7 @@ class Migration(migrations.Migration):
('dhcp_dynamic', models.BooleanField(default=False)),
('dhcp_dynamic_start', models.IPAddressField(null=True, blank=True)),
('dhcp_dynamic_end', models.IPAddressField(null=True, blank=True)),
('dhcp_config', models.ForeignKey(to='mdb.DhcpConfig', on_delete=models.CASCADE)),
('dhcp_config', models.ForeignKey(to='mdb.DhcpConfig')),
],
options={
'verbose_name': 'IPv4 subnet',
@@ -227,7 +229,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('address', models.CharField(max_length=64)),
('interface', models.ForeignKey(to='mdb.Interface', on_delete=models.CASCADE)),
('interface', models.ForeignKey(to='mdb.Interface')),
],
options={
'verbose_name': 'IPv6 address',
@@ -307,7 +309,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='operatingsystem',
name='architecture',
field=models.ForeignKey(to='mdb.OsArchitecture', on_delete=models.CASCADE),
field=models.ForeignKey(to='mdb.OsArchitecture'),
preserve_default=True,
),
migrations.AddField(
@@ -319,7 +321,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='ip6address',
name='subnet',
field=models.ForeignKey(to='mdb.Ip6Subnet', on_delete=models.CASCADE),
field=models.ForeignKey(to='mdb.Ip6Subnet'),
preserve_default=True,
),
migrations.AddField(
@@ -331,25 +333,25 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='ip4address',
name='subnet',
field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
field=models.ForeignKey(to='mdb.Ip4Subnet'),
preserve_default=True,
),
migrations.AddField(
model_name='interface',
name='ip4address',
field=models.ForeignKey(null=True, blank=True, to='mdb.Ip4Address', unique=True, on_delete=models.SET_NULL),
field=models.ForeignKey(null=True, blank=True, to='mdb.Ip4Address', unique=True),
preserve_default=True,
),
migrations.AddField(
model_name='host',
name='host_type',
field=models.ForeignKey(to='mdb.HostType', on_delete=models.CASCADE),
field=models.ForeignKey(to='mdb.HostType'),
preserve_default=True,
),
migrations.AddField(
model_name='host',
name='operating_system',
field=models.ForeignKey(to='mdb.OperatingSystem', on_delete=models.CASCADE),
field=models.ForeignKey(to='mdb.OperatingSystem'),
preserve_default=True,
),
migrations.AddField(
@@ -367,13 +369,13 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='dhcpoption',
name='ip4subnet',
field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
field=models.ForeignKey(to='mdb.Ip4Subnet'),
preserve_default=True,
),
migrations.AddField(
model_name='dhcpcustomfield',
name='ip4subnet',
field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
field=models.ForeignKey(to='mdb.Ip4Subnet'),
preserve_default=True,
),
]
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.core.validators
import re
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.db.migrations import RunPython
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
+3 -1
View File
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.core.validators
import re
@@ -33,7 +35,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='interface',
name='ip4address',
field=models.OneToOneField(to='mdb.Ip4Address', null=True, blank=True, on_delete=models.SET_NULL),
field=models.OneToOneField(to='mdb.Ip4Address', null=True, blank=True),
),
migrations.AlterField(
model_name='interface',
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import re
import django.core.validators
+3 -1
View File
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
@@ -16,7 +18,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=256)),
('target', models.GenericIPAddressField(protocol='IPv6')),
('created_date', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
('domain', models.ForeignKey(to='mdb.Domain')),
],
),
migrations.AlterField(
-28
View File
@@ -1,28 +0,0 @@
# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mdb', '0010_auto_20151004_1709'),
]
operations = [
migrations.AddField(
model_name='operatingsystem',
name='pxe_initrd',
field=models.CharField(null=True, blank=True, max_length=255),
),
migrations.AddField(
model_name='operatingsystem',
name='pxe_kernel',
field=models.CharField(null=True, blank=True, max_length=255),
),
migrations.AddField(
model_name='operatingsystem',
name='pxe_preseed_config_url',
field=models.URLField(null=True, blank=True),
),
]
@@ -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),
]
+245 -199
View File
@@ -1,13 +1,17 @@
import datetime
import ipaddress
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.db.models import Count
from django.template.loader import render_to_string
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from mdb.utils import host_as_pxe_files
from mdb.pxe import host_as_pxe_files
from mdb.validators import validate_hostname, validate_macaddr
import ipaddress
import datetime
@python_2_unicode_compatible
class Nameserver(models.Model):
hostname = models.CharField(max_length=256)
@@ -15,6 +19,7 @@ class Nameserver(models.Model):
return self.hostname
@python_2_unicode_compatible
class MailExchange(models.Model):
priority = models.IntegerField()
hostname = models.CharField(max_length=256)
@@ -23,6 +28,7 @@ class MailExchange(models.Model):
return "(" + str(self.priority) + ") " + self.hostname
@python_2_unicode_compatible
class Domain(models.Model):
domain_name = models.CharField(max_length=256)
domain_soa = models.CharField(max_length=256)
@@ -34,10 +40,8 @@ class Domain(models.Model):
domain_expire = models.IntegerField(default=604800)
domain_minimum_ttl = models.IntegerField(default=86400)
domain_admin = models.EmailField()
domain_ipaddr = models.GenericIPAddressField(protocol="IPv4")
domain_ip6addr = models.GenericIPAddressField(
protocol="IPv6", blank=True, null=True
)
domain_ipaddr = models.GenericIPAddressField(protocol='IPv4')
domain_ip6addr = models.GenericIPAddressField(protocol='IPv6', blank=True, null=True)
domain_filename = models.CharField(max_length=256)
created_date = models.DateTimeField(auto_now_add=True)
@@ -47,40 +51,54 @@ class Domain(models.Model):
def __str__(self):
return self.domain_name
def domain_admin_formatted(self):
return self.domain_admin.replace("@", ".")
def num_records(self):
host_a_records = Host.objects.filter(interface__domain=self).count()
size = {
"CNAME": self.domaincnamerecord_set.count(),
"SRV": self.domainsrvrecord_set.count(),
"TXT": self.domaintxtrecord_set.count(),
"A": self.domainarecord_set.count() + host_a_records
}
return ', '.join(['{}: {}'.format(k, v) for k, v in size.items()])
num_records.short_description = "Num Records"
def __eq__(self, other):
if not other or not hasattr(other, "domain_name"):
if not other or not hasattr(other, 'domain_name'):
return False
return self.domain_name == other.domain_name
def zone_file_contents(self):
content = f"; serial:{self.domain_serial:d}\n"
content += f"; zone file for {self.domain_name}\n"
content += f"; {datetime.datetime.now()}\n"
content += f"; filename: {self.domain_filename}\n"
content += f"$TTL {self.domain_ttl}\n"
content += "@ IN SOA {}. {}. (\n".format(
content = "; serial:%d\n" % self.domain_serial
content += "; zone file for %s\n" % self.domain_name
content += "; %s\n" % datetime.datetime.now()
content += "; filename: %s\n" % self.domain_filename
content += "$TTL %s\n" % self.domain_ttl
content += "@ IN SOA %s. %s. (\n" % (
self.domain_soa,
self.domain_admin.replace("@", "."),
)
content += f"\t{self.domain_serial:d}\t; serial\n"
content += f"\t{self.domain_refresh:d}\t; refresh\n"
content += f"\t{self.domain_retry:d}\t; retry\n"
content += f"\t{self.domain_expire:d}\t; expire\n"
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
self.domain_admin.replace("@", "."))
content += "\t%d\t; serial\n" % self.domain_serial
content += "\t%d\t; refresh\n" % self.domain_refresh
content += "\t%d\t; retry\n" % self.domain_retry
content += "\t%d\t; expire\n" % self.domain_expire
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
content += ";\n"
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():
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:
content += f"@\tIN\tA\t{self.domain_ipaddr}\n"
content += "@\tIN\tA\t%s\n" % self.domain_ipaddr
if self.domain_ip6addr:
content += f"@\tIN\tAAAA\t{self.domain_ip6addr}\n"
if self.domain_ip6addr is not None:
content += "@\tIN\tAAAA\t%s\n" % self.domain_ip6addr
content += "; SRV records\n"
@@ -100,7 +118,7 @@ class Domain(models.Model):
content += "; CNAME records \n"
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"
@@ -112,21 +130,25 @@ class Domain(models.Model):
for interface in self.interface_set.all():
host = interface.host
if interface.ip4address:
content += (
f"{host.hostname:<20}\tIN\tA\t{interface.ip4address.address}\n"
)
content += "%-20s\tIN\tA\t%s\n" % (
host.hostname,
interface.ip4address.address)
for ipv6addr in interface.ip6address_set.all():
content += f"{host.hostname:<20}\tIN\tAAAA\t{ipv6addr.full_address()}\n"
if host.allow_email and interface.ip4address:
content += f'{host.hostname}\tTXT\t"v=spf1 mx ip4:{interface.ip4address.address} -all"\n'
elif host.allow_email:
content += f'{host.hostname}\tTXT\t"v=spf1 mx -all"\n'
content += "%-20s\tIN\tAAAA\t%s\n" % (
host.hostname,
ipv6addr.full_address())
return content
def zone_file_contents_alt(self):
context = {
'domain': self,
'now': datetime.datetime.now()
}
return render_to_string('zone_file.txt', context=context)
@python_2_unicode_compatible
class DomainSrvRecord(models.Model):
srvce = models.CharField(max_length=64)
prot = models.CharField(max_length=64)
@@ -135,71 +157,82 @@ class DomainSrvRecord(models.Model):
weight = models.IntegerField()
port = models.IntegerField()
target = models.CharField(max_length=256)
domain = models.ForeignKey(Domain, models.CASCADE)
domain = models.ForeignKey(Domain)
created_date = models.DateTimeField(auto_now_add=True)
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):
return self.as_record()
@python_2_unicode_compatible
class DomainTxtRecord(models.Model):
name = models.CharField(max_length=256)
# note that the max length of a TXT record is 255, but
# bind allows for longer records by splitting them
# into chunks as: "...255..." "...another 255..."
target = models.CharField(max_length=2048)
domain = models.ForeignKey(Domain, models.CASCADE)
target = models.CharField(max_length=256)
domain = models.ForeignKey(Domain)
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return f"{self.name} TXT {self.target}"
return '{} TXT {}'.format(self.name, self.target)
def __str__(self):
return self.as_record()
@python_2_unicode_compatible
class DomainCnameRecord(models.Model):
name = models.CharField(max_length=256)
target = models.CharField(max_length=256)
domain = models.ForeignKey(Domain, models.CASCADE)
domain = models.ForeignKey(Domain)
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return f"{self.name} IN CNAME {self.target}"
return '{} IN CNAME {}'.format(self.name, self.target)
def __str__(self):
return self.as_record()
@python_2_unicode_compatible
class DomainARecord(models.Model):
name = models.CharField(max_length=256)
target = models.GenericIPAddressField(protocol="IPv4")
domain = models.ForeignKey(Domain, models.CASCADE)
target = models.GenericIPAddressField(protocol='IPv4')
domain = models.ForeignKey(Domain)
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return f"{self.name} IN A {self.target}"
return '{} IN A {}'.format(self.name, self.target)
def __str__(self):
return self.as_record()
@python_2_unicode_compatible
class DomainAAAARecord(models.Model):
name = models.CharField(max_length=256)
target = models.GenericIPAddressField(protocol="IPv6")
domain = models.ForeignKey(Domain, models.CASCADE)
target = models.GenericIPAddressField(protocol='IPv6')
domain = models.ForeignKey(Domain)
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return f"{self.name} IN AAAA {self.target}"
return '{} IN AAAA {}'.format(self.name, self.target)
def __str__(self):
return self.as_record()
@python_2_unicode_compatible
class DhcpConfig(models.Model):
serial = models.IntegerField()
active_serial = models.IntegerField()
@@ -211,48 +244,45 @@ class DhcpConfig(models.Model):
log_facility = models.CharField(max_length=255)
def dhcpd_configuration(self):
content = f"# Autogenerated configuration {datetime.datetime.now()}\n"
content = "# Autogenerated configuration %s\n" % datetime.datetime.now()
if self.authoritative:
content += "authoritative;\n"
content += f"default-lease-time {self.default_lease_time:d};\n"
content += f"max-lease-time {self.max_lease_time:d};\n"
content += f"log-facility {self.log_facility};\n"
content += f"ddns-update-style {self.ddns_update_style};\n"
content += "default-lease-time %d;\n" % self.default_lease_time
content += "max-lease-time %d;\n" % self.max_lease_time
content += "log-facility %s;\n" % self.log_facility
content += "ddns-update-style %s;\n" % self.ddns_update_style
# time to write the subnet definitions
for subnet in self.ip4subnet_set.all():
content += f"\n# {subnet.name}\n"
content += f"subnet {subnet.network} netmask {subnet.netmask} {{\n"
content += "\n# %s\n" % subnet.name
content += "subnet %s netmask %s {\n" % (
subnet.network,
subnet.netmask)
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():
content += f"\t{option.value};\n"
content += "\t%s;\n" % option.value
if subnet.dhcp_dynamic:
content += (
f"\trange {subnet.dhcp_dynamic_start} {subnet.dhcp_dynamic_end};\n"
)
content += "\trange %s %s;\n" \
% (subnet.dhcp_dynamic_start, subnet.dhcp_dynamic_end)
content += "}\n"
# time to write host definitions
for subnet in self.ip4subnet_set.all():
for ip4address in subnet.ip4address_set.all():
if (
not hasattr(ip4address, "interface")
or not ip4address.interface.dhcp_client
):
if not hasattr(ip4address, 'interface') or not ip4address.interface.dhcp_client:
continue
_if = ip4address.interface
content += f"\nhost {_if.host.hostname} {{\n"
content += f"\thardware ethernet {_if.macaddr};\n"
content += (
f"\tfixed-address {_if.host.hostname}.{_if.domain.domain_name};\n"
)
content += "\nhost %s {\n" % _if.host.hostname
content += "\thardware ethernet %s;\n" % _if.macaddr
content += "\tfixed-address %s.%s;\n" % \
(_if.host.hostname, _if.domain.domain_name)
if len(_if.pxe_filename) > 0:
content += f'\tfilename "{_if.pxe_filename}";\n'
content += "\tfilename \"%s\";\n" % _if.pxe_filename
content += "}\n"
return content
@@ -261,9 +291,10 @@ class DhcpConfig(models.Model):
return self.name
class Meta:
verbose_name = "DHCP config"
verbose_name = 'DHCP config'
@python_2_unicode_compatible
class Ip6Subnet(models.Model):
name = models.CharField(max_length=255)
network = models.CharField(max_length=255)
@@ -285,38 +316,25 @@ class Ip6Subnet(models.Model):
def __str__(self):
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):
content = ""
content += f"; zone file for {self.domain_name}\n"
content += f"; {datetime.datetime.now()}\n"
content += f"; filename: {self.domain_filename}\n"
content += f"$TTL {self.domain_ttl}\n"
content += "@ IN SOA {}. {}. (\n".format(
content += "; zone file for %s\n" % self.domain_name
content += "; %s\n" % datetime.datetime.now()
content += "; filename: %s\n" % self.domain_filename
content += "$TTL %s\n" % self.domain_ttl
content += "@ IN SOA %s. %s. (\n" % (
self.domain_soa,
self.domain_admin.replace("@", "."),
)
content += f"\t{self.domain_serial:d}\t; serial\n"
content += f"\t{self.domain_refresh:d}\t; refresh\n"
content += f"\t{self.domain_retry:d}\t; retry\n"
content += f"\t{self.domain_expire:d}\t; expire\n"
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
self.domain_admin.replace("@", "."))
content += "\t%d\t; serial\n" % self.domain_serial
content += "\t%d\t; refresh\n" % self.domain_refresh
content += "\t%d\t; retry\n" % self.domain_retry
content += "\t%d\t; expire\n" % self.domain_expire
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
content += ";\n"
content += ";\n"
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"
@@ -325,17 +343,17 @@ class Ip6Subnet(models.Model):
# content += "$ORIGIN " + ".".join(network.exploded.replace(":","")[:16])[::-1] + ".ip6.arpa.\n"
for addr in self.ip6address_set.all():
# if addr.interface_set.count() == 0: continue
#if addr.interface_set.count() == 0: continue
if addr.interface.domain is None:
continue
hostname = (
f"{addr.interface.host.hostname}.{addr.interface.domain.domain_name}"
)
hostname = "%s.%s" % (
addr.interface.host.hostname,
addr.interface.domain.domain_name)
ip = ipaddress.IPv6Address(self.network + addr.address)
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.
# content += "%20s\tIN\tPTR\t%s.\n" % (addr.address.split(".")[3], hostname)
@@ -343,13 +361,14 @@ class Ip6Subnet(models.Model):
return content
class Meta:
verbose_name = "IPv6 subnet"
verbose_name = 'IPv6 subnet'
@python_2_unicode_compatible
class Ip4Subnet(models.Model):
name = models.CharField(max_length=256)
netmask = models.GenericIPAddressField(protocol="IPv4")
network = models.GenericIPAddressField(protocol="IPv4")
netmask = models.GenericIPAddressField(protocol='IPv4')
network = models.GenericIPAddressField(protocol='IPv4')
created_date = models.DateTimeField(auto_now_add=True)
domain_name = models.CharField(max_length=255, editable=False)
domain_nameservers = models.ManyToManyField(Nameserver)
@@ -365,109 +384,122 @@ class Ip4Subnet(models.Model):
domain_filename = models.CharField(max_length=256)
dhcp_dynamic = models.BooleanField(default=False)
dhcp_dynamic_start = models.GenericIPAddressField(
protocol="IPv4", null=True, blank=True
)
dhcp_dynamic_end = models.GenericIPAddressField(
protocol="IPv4", null=True, blank=True
)
dhcp_config = models.ForeignKey(DhcpConfig, models.CASCADE)
dhcp_dynamic_start = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True)
dhcp_dynamic_end = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True)
dhcp_config = models.ForeignKey(DhcpConfig)
def __str__(self):
return self.network + " (" + self.name + ")"
@property
def reverse_zone_name(self):
"""The in-addr.arpa reverse zone name for this subnet.
def num_addresses(self):
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask)
return subnet.num_addresses
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 broadcast_address(self):
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask)
return subnet.broadcast_address
def first_address(self):
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask)
return next(subnet.hosts())
def last_address(self):
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask)
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 zone_file_contents(self, generate_unassigned=False):
content = ""
content += f"; zone file for {self.domain_name}\n"
content += f"; {datetime.datetime.now()}\n"
content += f"; filename: {self.domain_filename}\n"
content += f"$TTL {self.domain_ttl}\n"
content += "@ IN SOA {}. {}. (\n".format(
content += "; zone file for %s\n" % self.domain_name
content += "; %s\n" % datetime.datetime.now()
content += "; filename: %s\n" % self.domain_filename
content += "$TTL %s\n" % self.domain_ttl
content += "@ IN SOA %s. %s. (\n" % (
self.domain_soa,
self.domain_admin.replace("@", "."),
)
content += f"\t{self.domain_serial:d}\t; serial\n"
content += f"\t{self.domain_refresh:d}\t; refresh\n"
content += f"\t{self.domain_retry:d}\t; retry\n"
content += f"\t{self.domain_expire:d}\t; expire\n"
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
self.domain_admin.replace("@", "."))
content += "\t%d\t; serial\n" % self.domain_serial
content += "\t%d\t; refresh\n" % self.domain_refresh
content += "\t%d\t; retry\n" % self.domain_retry
content += "\t%d\t; expire\n" % self.domain_expire
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
content += ";\n"
content += ";\n"
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"
for addr in self.ip4address_set.all():
""" Get PTR records for each v4 address in subnet """
if hasattr(addr, "interface") and addr.interface.domain:
hostname = f"{addr.interface.host.hostname}.{addr.interface.domain.domain_name}"
content += f"{addr.address.split('.')[3]:<20}\tIN\tPTR\t{hostname}.\n"
elif not hasattr(addr, "interface") and generate_unassigned:
content += "{}\tIN\tPTR\t{}.{}\n".format(
if hasattr(addr, 'interface') and addr.interface.domain:
hostname = "%s.%s" % (
addr.interface.host.hostname,
addr.interface.domain.domain_name)
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.split(".")[3],
"dhcp.neuf.no.",
)
"dhcp.neuf.no.")
return content
class Meta:
verbose_name = "IPv4 subnet"
verbose_name = 'IPv4 subnet'
@python_2_unicode_compatible
class DhcpOption(models.Model):
key = models.CharField(max_length=255)
value = models.CharField(max_length=255)
ip4subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
ip4subnet = models.ForeignKey(Ip4Subnet)
def __str__(self):
return self.key + " " + self.value
class Meta:
verbose_name = "DHCP option"
verbose_name = 'DHCP option'
class DhcpCustomField(models.Model):
value = models.CharField(max_length=255)
ip4subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
ip4subnet = models.ForeignKey(Ip4Subnet)
class Meta:
verbose_name = "DHCP custom field"
verbose_name = 'DHCP custom field'
@python_2_unicode_compatible
class Ip4Address(models.Model):
subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
address = models.GenericIPAddressField(protocol="IPv4")
subnet = models.ForeignKey(Ip4Subnet)
address = models.GenericIPAddressField(protocol='IPv4')
last_contact = models.DateTimeField(null=True, blank=True)
ping_avg_rtt = models.FloatField(null=True, blank=True)
def __str__(self):
return self.address
# FIXME: generates 2 SQL queries?
if not hasattr(self, 'interface'):
return self.address
return "{} ({})".format(self.address, self.interface.host.hostname)
def assigned_to_host(self):
return self.interface.host
assigned_to_host.short_description = "Assigned to Host"
class Meta:
verbose_name = "IPv4 address"
verbose_name_plural = "IPv4 addresses"
verbose_name = 'IPv4 address'
verbose_name_plural = 'IPv4 addresses'
@python_2_unicode_compatible
class HostType(models.Model):
host_type = models.CharField(max_length=64)
description = models.CharField(max_length=1024)
@@ -482,27 +514,22 @@ class HostType(models.Model):
ordering = ("host_type",)
@python_2_unicode_compatible
class OperatingSystem(models.Model):
OS_ARCHITECTURES = (
("mips", _("MIPS")),
("arm", _("ARM")),
("broadcom", _("Broadcom")),
("rc32300", _("RC32300")),
("powerpc", _("PowerPC")),
("powerpc403ga", _("PowerPC403GA")),
("unknown", _("Unknown")),
("x86_64", _("x86-64")),
("i386", _("x86")),
('mips', _('MIPS')),
('arm', _('ARM')),
('broadcom', _('Broadcom')),
('rc32300', _('RC32300')),
('powerpc', _('PowerPC')),
('powerpc403ga', _('PowerPC403GA')),
('unknown', _('Unknown')),
('x86_64', _('x86-64')),
('i386', _('x86')),
)
name = models.CharField(max_length=256)
version = models.CharField(max_length=64)
arch = models.CharField(
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)
arch = models.CharField(max_length=255, choices=OS_ARCHITECTURES, blank=True, null=True)
def __str__(self):
return self.name + " " + self.version + " (" + self.arch + ")"
@@ -511,6 +538,7 @@ class OperatingSystem(models.Model):
ordering = ("name", "version")
@python_2_unicode_compatible
class Host(models.Model):
location = models.CharField(max_length=1024)
brand = models.CharField(max_length=1024)
@@ -520,19 +548,14 @@ class Host(models.Model):
serial_number = models.CharField(max_length=256)
description = models.CharField(max_length=1024)
created_date = models.DateTimeField(auto_now_add=True)
host_type = models.ForeignKey(HostType, models.CASCADE) # FIXME: SET_NULL?
host_type = models.ForeignKey(HostType)
virtual = models.BooleanField(default=False)
allow_email = models.BooleanField(default=False)
operating_system = models.ForeignKey(
OperatingSystem, models.CASCADE
) # FIXME: SET_NULL?
operating_system = models.ForeignKey(OperatingSystem)
request_kerberos_principal = models.BooleanField(default=False)
kerberos_principal_created = models.BooleanField(default=False, editable=False)
kerberos_principal_name = models.CharField(max_length=256, editable=False)
kerberos_principal_created_date = models.DateTimeField(
null=True, blank=True, editable=False
)
kerberos_principal_created_date = models.DateTimeField(null=True, blank=True, editable=False)
pxe_key = models.CharField(max_length=254, blank=True)
pxe_installable = models.BooleanField(default=False)
@@ -540,40 +563,63 @@ class Host(models.Model):
def __str__(self):
return self.hostname
def in_domain(self):
domains = self.interface_set.values_list('domain__domain_name', flat=True)
return ",".join(domains)
in_domain.short_description = "in domains"
def ipv6_enabled(self):
ipv6_ifs = self.interface_set.annotate(num_ipv6=Count('ip6address')).filter(num_ipv6__gt=0)
return ipv6_ifs.exists()
ipv6_enabled.boolean = True
def mac_addresses(self):
addresses = self.interface_set.filter(macaddr__isnull=False).values_list('macaddr', flat=True)
return ", ".join(addresses)
def ip_addresses(self):
addresses = self.interface_set.filter(ip4address__isnull=False).values_list('ip4address__address', flat=True)
return ", ".join(addresses)
def as_pxe_files(self):
return host_as_pxe_files(self)
@python_2_unicode_compatible
class Interface(models.Model):
name = models.CharField(max_length=128)
macaddr = models.CharField(max_length=17, validators=[validate_macaddr])
pxe_filename = models.CharField(max_length=64, blank=True)
dhcp_client = models.BooleanField(default=False)
host = models.ForeignKey(Host, models.CASCADE)
ip4address = models.OneToOneField(
Ip4Address, blank=True, null=True, on_delete=models.SET_NULL
)
host = models.ForeignKey(Host)
ip4address = models.OneToOneField(Ip4Address, blank=True, null=True)
created_date = models.DateTimeField(auto_now_add=True)
domain = models.ForeignKey(Domain, models.CASCADE)
domain = models.ForeignKey(Domain)
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):
return self.ip6address_set.exists()
@python_2_unicode_compatible
class Ip6Address(models.Model):
subnet = models.ForeignKey(Ip6Subnet, models.CASCADE)
address = models.GenericIPAddressField(protocol="IPv6")
interface = models.ForeignKey(Interface, models.CASCADE)
subnet = models.ForeignKey(Ip6Subnet)
address = models.GenericIPAddressField(protocol='IPv6')
interface = models.ForeignKey(Interface)
def full_address(self):
return self.subnet.network + self.address
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:
verbose_name = "IPv6 address"
verbose_name_plural = "IPv6 addresses"
verbose_name = 'IPv6 address'
verbose_name_plural = 'IPv6 addresses'
-60
View File
@@ -1,60 +0,0 @@
import ipaddress
from django.conf import settings
from django.template.loader import render_to_string
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 = f"{int(ipaddress.IPv4Address(interface.ip4address.address)):02X}"
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
-45
View File
@@ -1,45 +0,0 @@
import json
import requests
from django.conf import settings
MAX_CONTENT_LENGTH = 2000
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})
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")},
)
resp.raise_for_status()
-183
View File
@@ -1,183 +0,0 @@
import os
import re
import shutil
import sys
import tempfile
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, "", ""
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.
"""
return self.run_commands([self.freeze_command])
def reload(self):
"""Load the written zone files and resume dynamic updates."""
return self.run_commands([self.reload_command, self.thaw_command])
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)
with open(zonefile, "w") as f:
f.write(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 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}")
+16 -26
View File
@@ -1,13 +1,12 @@
import ipaddress
import logging
import os
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
import ipaddress
from mdb.models import Host, Interface, Ip4Address, Ip4Subnet, Ip6Subnet
from mdb.models import Interface, Host, Ip6Subnet, Ip4Subnet, Ip4Address
from mdb.utils import format_domain_serial_and_add_one
logger = logging.getLogger(__name__)
@@ -35,7 +34,9 @@ def delete_ips_for_subnet(sender, instance, **kwargs):
def set_domain_name_for_subnet(sender, instance, **kwargs):
# we assume that the reverse domain_name does not change
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
# if instance.domain_serial is not None:
@@ -45,9 +46,7 @@ def set_domain_name_for_subnet(sender, instance, **kwargs):
# when the subnet is changed
if instance.dhcp_config:
# instance.dhcp_config.serial = instance.dhcp_config.serial + 1
instance.dhcp_config.serial = format_domain_serial_and_add_one(
instance.dhcp_config.serial
)
instance.dhcp_config.serial = format_domain_serial_and_add_one(instance.dhcp_config.serial)
instance.dhcp_config.save()
@@ -56,7 +55,8 @@ def set_domain_name_for_ipv6_subnet(sender, instance, **kwargs):
if len(instance.domain_name) > 0:
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)
@@ -77,22 +77,16 @@ def update_domain_serial_when_change_to_host(sender, instance, created, **kwargs
for interface in instance.interface_set.all():
if interface.domain is not None:
domain = interface.domain
domain.domain_serial = format_domain_serial_and_add_one(
domain.domain_serial
)
domain.domain_serial = format_domain_serial_and_add_one(domain.domain_serial)
domain.save()
if interface.ip4address is not None:
subnet = interface.ip4address.subnet
subnet.domain_serial = format_domain_serial_and_add_one(
subnet.domain_serial
)
subnet.domain_serial = format_domain_serial_and_add_one(subnet.domain_serial)
subnet.save()
@receiver(post_save, sender=Host)
def create_pxe_key_and_write_pxe_files_when_host_changes(
sender, instance, created, **kwargs
):
def create_pxe_key_and_write_pxe_files_when_host_changes(sender, instance, created, **kwargs):
host = instance
if not host.pxe_key:
@@ -104,11 +98,9 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(
for pxe_file_name, pxe_file in host.as_pxe_files():
path = os.path.join(settings.MDB_PXE_TFTP_ROOT, pxe_file_name)
if host.pxe_installable:
# Write pxeconfig file to disk
with open(path, "w+") as f:
with open(path, 'w+') as f:
f.write(pxe_file)
logger.info(f"Created or updated {path}")
os.chmod(path, 0o644) # Not writeable for all
logger.info("Created or updated {}".format(path))
# Also set necessary fields for pxe installation
interfaces = host.interface_set.exclude(ip4address__isnull=True)
@@ -116,7 +108,7 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(
for _if in interfaces:
changed = False
if not _if.pxe_filename:
_if.pxe_filename = "pxelinux.0" # don't overwrite
_if.pxe_filename = 'pxelinux.0' # don't overwrite
changed = True
if not _if.dhcp_client:
_if.dhcp_client = True
@@ -127,9 +119,8 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(
else:
if os.path.exists(path):
# Delete pxeconfig file from disk
os.unlink(path)
logger.info(f"deleted {path}")
logger.info("deleted {}".format(path))
@receiver(pre_delete, sender=Interface)
@@ -144,7 +135,6 @@ def update_domain_serial_when_interface_deleted(sender, instance, **kwargs):
subnet.domain_serial += 1
subnet.save()
# @receiver(pre_save, sender=Domain)
# def update_domain_serial_when_domain_is_saved(sender, instance, **kwargs):
# instance.domain_serial = format_domain_serial_and_add_one(instance.domain_serial)
+3 -6
View File
@@ -1,7 +1,4 @@
default 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 initrd={{initrd}} -- snop={{host.pxe_key}}
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
timeout 0
+31
View File
@@ -0,0 +1,31 @@
; serial: {{ domain.domain_serial }}
; zone file for {{ domain.domain_name }}
; {{ now|date:"c" }}
; filename: {{ domain.domain_filename }}
$TTL {{ domain.domain_ttl }}
@ IN SOA {{ domain.domain_soa }}. {{ domain.domain_admin_formatted }}. (
{{ domain.domain_serial|stringformat:"-10s" }} ; serial
{{ domain.domain_refresh|stringformat:"-10s" }} ; refresh
{{ domain.domain_retry|stringformat:"-10s" }} ; retry
{{ domain.domain_expire|stringformat:"-10s" }} ; expire
{{ domain.domain_minimum_ttl }} ) ; minimum ttl
;
{% for nameserver in domain.domain_nameservers.all %}@ IN NS {{ nameserver.hostname }}.
{% endfor %}{% for mx in domain.domain_mailexchanges.all %}@ MX {{ mx.priority }} {{ mx.hostname }}.{% endfor %}
{% if domain.domain_ipaddr %}@ IN A {{ domain.domain_ipaddr }}{% endif %}
{% if domain.domain_ip6addr %}@ IN AAAA {{ domain.domain_ip6addr }}{% endif %}
; SRV records
{% for srv in domain.domainsrvrecord_set.all %}{{ srv.as_record }}{% endfor %}
; A records
{% for a in domain.domainarecord_set.all %}{{ a.as_record }}{% endfor %}
; AAAA records
{% for aaaa in domain.domainaaaarecord_set.all %}{{ aaaa.as_record }}{% endfor %}
; CNAME records
{% for cname in domain.domaincnamerecord_set.all %}{{ cname.name|stringformat:"-20s" }} IN CNAME {{ cname.target }}{% endfor %}
; TXT records
{% for txt in domain.domaintxtrecord_set.all %}{{ txt.as_record }}{% endfor %}
; HOST records
{% for interface in domain.interface_set.all %}
{% if interface.ip4address %}{{ interface.host.hostname|stringformat:"-20s" }} IN A {{ interface.ip4address.address }}{% endif %}
{% for ipv6addr in interface.ip6address_set.all %}{{ interface.host.hostname|stringformat:"-20s" }} IN AAAA {{ ipv6addr.full_address }}{% endfor %}
{% endfor %}
+56
View File
@@ -0,0 +1,56 @@
import io
from django.contrib.auth.models import User
from django.core.management import call_command
from django.core.urlresolvers import reverse
from django.test import TestCase
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
from mdb.models import Host
class MyAPITestCases(APITestCase):
fixtures = ['test_data']
def create_user_with_token(self):
self.user = User.objects.create(username='admin')
self.token = Token.objects.create(user=self.user)
self.host = Host.objects.first()
def setUp(self):
self.create_user_with_token()
return super(MyAPITestCases, self).setUp()
def test_validate_puppet_host_by_secret(self):
data = {
'pxe_key': self.host.pxe_key,
'certname': '{}.{}'.format(self.host.hostname, self.host.interface_set.first().domain.domain_name)
}
self.client.credentials(HTTP_AUTHORIZATION='Token {}'.format(self.token.key))
res = self.client.post(reverse('api-validate-host-secret'), data, format='json')
self.assertEquals(res.status_code, 200, res.content)
self.assertFalse(Host.objects.get(pk=self.host.pk).pxe_installable)
class UnitTests(TestCase):
fixtures = ['test_data']
def test_host_as_pxe_files(self):
host = Host.objects.first()
self.assertEqual(len(host.as_pxe_files()), 1)
class RunManagementCommands(TestCase):
fixtures = ['test_data']
def test_zone_sync(self):
expected_zone_file = ''
with io.BytesIO() as f:
call_command('zone-sync', debug=True, stdout=f)
# TODO check expected_zone_file
def test_dhcp_sync(self):
expected_dhcp_file = ''
with io.BytesIO() as f:
call_command('dhcp-sync', stdout=f)
# TODO check expected_dhcp_file
+56 -9
View File
@@ -1,25 +1,26 @@
import datetime
from django.conf import settings
from django.template.loader import render_to_string
import ipaddress
import re
def format_domain_serial_and_add_one(serial):
today = datetime.datetime.now()
res = re.findall(
rf"^{today.year:4d}{today.month:02d}{today.day:02d}(\d\d)$",
str(serial),
re.DOTALL,
)
res = re.findall("^%4d%02d%02d(\d\d)$" % (
today.year, today.month, today.day), str(serial), re.DOTALL)
if len(res) == 0:
""" This probably means that the serial is malformed
or the date is wrong. We assume that if the date is wrong,
it is in the past. Just create a new serial starting from 1."""
return f"{today.year:4d}{today.month:02d}{today.day:02d}01"
return "%4d%02d%02d%02d" % \
(today.year, today.month, today.day, 1)
elif len(res) == 1:
""" The serial contains todays date, just update it. """
try:
number = int(res[0])
except ValueError:
except:
number = 1
if number >= 99:
""" This is bad... Just keep the number on 99.
@@ -28,7 +29,53 @@ def format_domain_serial_and_add_one(serial):
else:
number += 1
return f"{today.year:4d}{today.month:02d}{today.day:02d}{number:02d}"
return "%4d%02d%02d%02d" % (
today.year, today.month, today.day, number)
else:
""" Just return the first serial for today. """
return f"{today.year:4d}{today.month:02d}{today.day:02d}01"
return "%4d%02d%02d%02d" % \
(today.year, today.month, today.day, 1)
def render_pxelinux_cfg(_if, host):
# TODO add fields to os model and make dynamic per host
distro = 'ubuntu/14_04/amd64/alternate/'
kernel = distro + 'linux'
initrd = distro + 'initrd.gz'
preseed_config_url = settings.MDB_PXE_PRESEED_URL
context = {
'kernel': kernel,
'initrd': initrd,
'preseed_config_url': preseed_config_url,
'host': host,
'interface': _if
}
return render_to_string('pxelinux.cfg', context=context)
def get_pxe_filename(interface, filename_format='mac_addr'):
"""
Mac or IP address based PXE filename
Ref: http://www.syslinux.org/wiki/index.php/PXELINUX#Examples
"""
ip_addr_in_hex = '{:02X}'.format(int(ipaddress.IPv4Address(interface.ip4address.address)))
formats = {
'ip_addr': ip_addr_in_hex,
'mac_addr': '01-{}'.format(interface.macaddr.lower().replace(':', '-'))
}
return formats.get(filename_format, formats['mac_addr'])
def host_as_pxe_files(host):
""" Returns a list of filename,content for each host interface with an IPv4 address """
from mdb.models import Host
assert isinstance(host, Host)
pxe_files = []
for _if in host.interface_set.exclude(ip4address__isnull=True):
filename = get_pxe_filename(_if)
content = render_pxelinux_cfg(_if, host)
pxe_files.append((filename, content))
return pxe_files
+10 -8
View File
@@ -1,14 +1,16 @@
from __future__ import unicode_literals
from django.core.validators import RegexValidator
import re
from django.core.validators import RegexValidator
# 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(
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(
regex=macaddr_re, message="Enter a valid MAC address", code="invalid"
)
regex=macaddr_re,
message='Enter a valid MAC address',
code='invalid')
+95 -114
View File
@@ -1,150 +1,131 @@
import os
from django.conf.global_settings import TEMPLATE_CONTEXT_PROCESSORS as TCP
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
# Off unless DJANGO_DEBUG says otherwise; mise.toml turns it on for development.
DEBUG = os.getenv("DJANGO_DEBUG", "").lower() in ("1", "true", "yes")
DEBUG = True
TEMPLATE_DEBUG = DEBUG
ADMINS = ("sysadmin@example.com",)
ADMINS = (
('System Administrator', 'sysadmin@example.com'),
)
MANAGERS = ADMINS
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "db.sqlite3",
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'db.sqlite3',
}
}
TIME_ZONE = "Europe/Oslo"
LANGUAGE_CODE = "en-us"
TIME_ZONE = 'Europe/Oslo'
LANGUAGE_CODE = 'en-us'
SITE_ID = 1
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")
MEDIA_URL = "/media/"
ADMIN_MEDIA_PREFIX = "/media/"
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATIC_URL = '/static/'
# Static
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
STATIC_URL = "/static/"
# Additional locations of static files
STATICFILES_DIRS = (
)
# List of finder classes that know how to find static files in
# various locations.
STATICFILES_FINDERS = (
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# 'django.contrib.staticfiles.finders.DefaultStorageFinder',
)
# For development only: the repository is public. Production sets its own (MDB_SETTINGS).
SECRET_KEY = "@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl"
# Make this unique, and don't share it with anybody.
SECRET_KEY = '@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl'
MIDDLEWARE = (
# Serves STATIC_ROOT, so production needs nothing in front but a proxy.
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
MIDDLEWARE_CLASSES = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
)
ROOT_URLCONF = "mdbsite.urls"
ROOT_URLCONF = 'mdbsite.urls'
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
],
},
},
]
TEMPLATE_DIRS = (
# Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
# Always use forward slashes, even on Windows.
# Don't forget to use absolute paths, not relative paths.
)
INSTALLED_APPS = (
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.admin",
"rest_framework",
"rest_framework.authtoken",
"django_extensions",
"mdb",
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.staticfiles',
'suit',
'django.contrib.admin',
'rest_framework',
'rest_framework.authtoken',
'mdb',
)
EMAIL_SUBJECT_PREFIX = "[MDB] "
EMAIL_SUBJECT_PREFIX = '[MDB] '
# Logging
LOGGING = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"verbose": {
"format": "%(levelname)s %(asctime)s %(module)s "
"%(process)d %(thread)d %(message)s"
TEMPLATE_CONTEXT_PROCESSORS = TCP + (
'django.core.context_processors.request', # for django-suit
)
# 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')
},
},
"handlers": {
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose",
}
},
"loggers": {
"root": {
"level": "WARNING",
"handlers": ["console"],
},
"django.db.backends": {
"level": "ERROR",
"handlers": ["console"],
"propagate": False,
},
},
),
# misc
'LIST_PER_PAGE': 30
}
# REST Framework
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"PAGINATE_BY": 10,
'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"
MDB_PXE_TFTP_ROOT = '/var/lib/tftpboot/pxelinux/pxelinux.cfg/'
MDB_PXE_PRESEED_URL = 'http://158.36.190.194/ubuntu/preseed_1404.cfg'
# Sentry
SENTRY_DSN = os.getenv("SENTRY_DSN")
SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONENT", "dev")
ADMIN_SITE_NAME = "MDB"
# Production's settings live outside the code, in the file MDB_SETTINGS names; the mdb role
# in edb-infra writes it. An error in it stops MDB rather than leaving the defaults above.
if os.getenv("MDB_SETTINGS"):
with open(os.environ["MDB_SETTINGS"]) as f:
exec(compile(f.read(), os.environ["MDB_SETTINGS"], "exec"))
else:
# Development. Only a missing local_settings is skipped: a local_settings whose own
# imports fail must not leave MDB running on the defaults.
try:
from .local_settings import * # noqa: F403
except ModuleNotFoundError as e:
if e.name != "mdbsite.local_settings":
raise
try:
from .local_settings import *
except ImportError:
pass
+4 -9
View File
@@ -1,20 +1,15 @@
from django.conf.urls import include, url
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import path
from django.views.generic.base import RedirectView
from mdb.api.views import HostPXEValidate
admin.autodiscover()
urlpatterns = [
path("", RedirectView.as_view(url="admin/", permanent=True)),
path("admin/", admin.site.urls),
path(
"api/validate-host-secret/",
HostPXEValidate.as_view(),
name="api-validate-host-secret",
),
url(r'^$', RedirectView.as_view(url='admin/', permanent=True)),
url(r'^admin/', include(admin.site.urls)),
url(r'^api/validate-host-secret/', HostPXEValidate.as_view(), name='api-validate-host-secret')
]
urlpatterns += staticfiles_urlpatterns()
+1 -3
View File
@@ -1,7 +1,5 @@
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdb.settings")
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdbsite.settings")
application = get_wsgi_application()
-7
View File
@@ -1,7 +0,0 @@
[tools]
python = "3.14"
uv = "latest"
[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/prek.yaml).
[[repos]]
repo = "https://github.com/astral-sh/ruff-pre-commit"
rev = "v0.15.6" # 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.0"] }]
[[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
-58
View File
@@ -1,58 +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"
license = "GPL-2.0-or-later"
dependencies = [
"django>=6.0.3",
"django-extensions>=4.1",
"djangorestframework>=3.17.0",
"gunicorn>=25.1.0",
"ipaddress>=1.0.23,<2.0.0",
"requests>=2.32.5",
"sentry-sdk>=2.55.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.3.0",
]
dev = [
"freezegun>=1.5.5",
"pytest>=9.0.2",
"pytest-django>=4.12.0",
"ruff>=0.15.6,<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"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+7
View File
@@ -0,0 +1,7 @@
Django>=1.8,<1.9
django-auth-ldap
django-extensions==1.5.7
djangorestframework==3.2.4
django-suit==0.2.15
ipaddress==1.0.14
mysqlclient==1.3.6
+8
View File
@@ -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
-43
View File
@@ -1,43 +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
-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("os.getlogin", 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("os.getlogin", 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"]
-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
-45
View File
@@ -1,45 +0,0 @@
import pytest
from django.conf import settings
from django.core.management import call_command
from mdb.models import Host, OperatingSystem
from mdb.pxe import render_pxelinux_cfg
@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()
@pytest.mark.django_db
def test_render_pxeconfig(host):
interface = host.interface_set.exclude(ip4address__isnull=True).first()
config = render_pxelinux_cfg(interface, host)
assert settings.MDB_PXE_INITRD in config
assert settings.MDB_PXE_KERNEL in config
assert settings.MDB_PXE_PRESEED_URL in config
@pytest.mark.django_db
def test_render_pxeconfig_with_os_pxe_config(host):
_os = OperatingSystem.objects.get(pk=2) # OS with all pxe vars set
Host.objects.filter(pk=host.pk).update(operating_system=_os)
host = Host.objects.first()
interface = host.interface_set.exclude(ip4address__isnull=True).first()
config = render_pxelinux_cfg(interface, host)
assert _os.pxe_initrd in config
assert _os.pxe_kernel in config
assert _os.pxe_preseed_config_url in config
@pytest.mark.django_db
def test_host_as_pxe_files(host):
assert len(host.as_pxe_files()) == 1
-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")
-67
View File
@@ -1,67 +0,0 @@
import pytest
from mdb.models import DhcpConfig, 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"
-322
View File
@@ -1,322 +0,0 @@
import os
import subprocess
from unittest.mock import MagicMock
import pytest
from freezegun import freeze_time
from mdb.models import (
DhcpConfig,
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
-210
View File
@@ -1,210 +0,0 @@
import os
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
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("os.getlogin", 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("os.getlogin", 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, "run_commands") as mock_run,
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
patch("os.getlogin", return_value="testuser"),
pytest.raises(SystemExit),
):
cmd.handle(debug=False, force=False, zone=None)
assert not os.path.exists(db_domain.domain_filename)
mock_run.assert_called_once_with([ZoneSyncService.thaw_command])
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") as mock_reload,
patch.object(ZoneSyncService, "sync_zone", side_effect=RuntimeError),
pytest.raises(RuntimeError),
):
cmd.handle(debug=False, force=False, zone=None)
mock_reload.assert_called_once()
Generated
-417
View File
@@ -1,417 +0,0 @@
version = 1
revision = 5
requires-python = ">=3.14"
[[package]]
name = "asgiref"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
]
[[package]]
name = "certifi"
version = "2025.4.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
]
[[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.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" },
]
[[package]]
name = "django-auth-ldap"
version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "python-ldap" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/6d/d3ceb4b49e7153811a4b2d92bbe198a5ef2e2820469add3d6dc129ef2fab/django_auth_ldap-5.3.0.tar.gz", hash = "sha256:743d8107b146240b46f7e97207dc06cb11facc0cd70dce490b7ca09dd5643d19", size = 55272, upload-time = "2025-12-26T15:00:14.272Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/91/38ba24b9d76925ce166b2eebe1b4ea460063b8ba8cf91d39d97ee3bad517/django_auth_ldap-5.3.0-py3-none-any.whl", hash = "sha256:aa880415983149b072f876d976ef8ec755a438090e176817998263a6ed9e1038", size = 20975, upload-time = "2025-12-26T15:00:12.52Z" },
]
[[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.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/46/615ad5425c7b941e6fd3f382d25b1aca680022200d8e5b87bfc665c4425d/djangorestframework-3.17.0.tar.gz", hash = "sha256:456fd992a33f9e64c9d0f47e85d9787db0efb44f894c1e513315b5e74765bd4c", size = 905671, upload-time = "2026-03-18T20:33:42.861Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ee/01dfe0e712d37007a4417bf7ba518a9e6830d4de009ed4bfdfebd45b99cf/djangorestframework-3.17.0-py3-none-any.whl", hash = "sha256:d84fe85f30b7ac6e8c0076ce9ff635e4eaedca5912f8d7d2926ce448c08533ba", size = 898818, upload-time = "2026-03-18T20:33:41.335Z" },
]
[[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 = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "ipaddress"
version = "1.0.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/9a/3e9da40ea28b8210dd6504d3fe9fe7e013b62bf45902b458d1cdc3c34ed9/ipaddress-1.0.23.tar.gz", hash = "sha256:b7f8e0369580bb4a24d5ba1d7cc29660a4a6987763faf1d8a8046830e020e7e2", size = 32958, upload-time = "2019-10-18T01:30:24.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/f8/49697181b1651d8347d24c095ce46c7346c37335ddc7d255833e7cde674d/ipaddress-1.0.23-py2.py3-none-any.whl", hash = "sha256:6e0f4a39e66cb5bb9a137b00276a2eff74f93b71dcbdad6f10ff7df9d3557fcc", size = 18159, upload-time = "2019-10-18T01:30:27.002Z" },
]
[[package]]
name = "mdb"
version = "0.1.0"
source = { editable = "." }
default-groups = ["dev", "prod"]
dependencies = [
{ name = "django" },
{ name = "django-extensions" },
{ name = "djangorestframework" },
{ name = "gunicorn" },
{ name = "ipaddress" },
{ 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.0.3" },
{ name = "django-extensions", specifier = ">=4.1" },
{ name = "djangorestframework", specifier = ">=3.17.0" },
{ name = "gunicorn", specifier = ">=25.1.0" },
{ name = "ipaddress", specifier = ">=1.0.23,<2.0.0" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "sentry-sdk", specifier = ">=2.55.0" },
{ name = "whitenoise", specifier = ">=6.12,<7" },
]
[package.metadata.requires-dev]
dev = [
{ name = "freezegun", specifier = ">=1.5.5" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-django", specifier = ">=4.12.0" },
{ name = "ruff", specifier = ">=0.15.6,<1.0.0" },
]
prod = [{ name = "django-auth-ldap", specifier = ">=5.3.0" }]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
]
[[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.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-django"
version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-ldap"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/8b/1eeb4025dc1d3ac2f16678f38dec9ebdde6271c74955b72db5ce7a4dbfbd/python-ldap-3.4.4.tar.gz", hash = "sha256:7edb0accec4e037797705f3a05cbf36a9fde50d08c8f67f2aef99a2628fab828", size = 377889, upload-time = "2023-11-17T21:14:16.32Z" }
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "ruff"
version = "0.15.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.55.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/b8/285293dc60fc198fffc3fcdbc7c6d4e646e0f74e61461c355d40faa64ceb/sentry_sdk-2.55.0.tar.gz", hash = "sha256:3774c4d8820720ca4101548131b9c162f4c9426eb7f4d24aca453012a7470f69", size = 424505, upload-time = "2026-03-17T14:15:51.707Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/66/20465097782d7e1e742d846407ea7262d338c6e876ddddad38ca8907b38f/sentry_sdk-2.55.0-py2.py3-none-any.whl", hash = "sha256:97026981cb15699394474a196b88503a393cbc58d182ece0d3abe12b9bd978d4", size = 449284, upload-time = "2026-03-17T14:15:49.604Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "urllib3"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" },
]
[[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" },
]