Compare commits

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

very fast

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

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,14 @@
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
+36 -11
View File
@@ -1,21 +1,46 @@
A Django app for managing computer equipment.
## Installation
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
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`.
## Development tasks
Dump test data:
python manage.py dumpdata mdb --natural-foreign --indent=4 > mdb/fixtures/test_data.json
uv run python manage.py dumpdata mdb --natural-foreign --indent=4 > mdb/fixtures/test_data.json
## Features
* 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,4 +5,5 @@ 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
@@ -1 +0,0 @@
default_app_config = 'mdb.apps.MdbAppConfig'
+236 -51
View File
@@ -1,8 +1,45 @@
from django.contrib import admin, messages
import ipaddress
from mdb.models import Ip6Address, Interface, Ip4Address, DhcpOption, DhcpCustomField, DomainSrvRecord, DomainTxtRecord, \
DomainCnameRecord, DomainARecord, Domain, Host, Ip4Subnet, Ip6Subnet, Nameserver, MailExchange, OperatingSystem, \
HostType, DhcpConfig, DomainAAAARecord
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
class Ip6AddressInline(admin.TabularInline):
@@ -14,64 +51,119 @@ class InterfaceInline(admin.TabularInline):
inlines = [Ip6AddressInline]
model = Interface
extra = 0
form = InterfaceForm
def get_queryset(self, request):
return super(InterfaceInline, self).get_queryset(request).select_related()
return super().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']
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']
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",
]
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'))
}),
# 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'))
# }),
("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",
)
},
),
)
actions = ['set_installable']
actions = ["set_installable"]
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 mac_addresses(self, host):
addresses = host.interface_set.filter(macaddr__isnull=False).values_list(
"macaddr", flat=True
)
return ", ".join(addresses)
def ip_addresses(self, host):
addresses = host.interface_set.filter(ip4address__isnull=False).values_list(
"ip4address__address", flat=True
)
return ", ".join(addresses)
@admin.display(description="in domains")
def in_domain(self, host):
domains = host.interface_set.values_list("domain__domain_name", flat=True)
return ",".join(domains)
@admin.display(boolean=True)
def ipv6_enabled(self, host):
ipv6_ifs = host.interface_set.annotate(num_ipv6=Count("ip6address")).filter(
num_ipv6__gt=0
)
return ipv6_ifs.exists()
# FIXME: custom filter to sort custom field
@staticmethod
def _get_host_warning_message(host, ifs):
ifs_str = ", ".join([_if.name for _if in ifs])
return f"Host {host} might not be installable since interface(s) {ifs_str} has no IP set."
@admin.action(description="Mark selected hosts as PXE installable")
def set_installable(self, request, queryset):
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)
set_installable.short_description = "Mark selected hosts as PXE installable"
self.message_user(
request, message="\n".join(invalid_ifs_per_host), level=messages.WARNING
)
def get_queryset(self, request):
return super(HostAdmin, self).get_queryset(request).select_related()
return super().get_queryset(request).select_related()
class Ip4AddressInline(admin.TabularInline):
model = Ip4Address
extra = 0
readonly_fields = ['address', 'assigned_to_host']
readonly_fields = ["address", "assigned_to_host"]
@admin.display(description="Assigned to Host")
def assigned_to_host(self, addr):
return addr.interface.host
class DhcpOptionInline(admin.TabularInline):
@@ -84,13 +176,76 @@ class DhcpCustomFieldInline(admin.TabularInline):
extra = 0
class SubnetAdmin(admin.ModelAdmin):
list_display = ['name', 'network', 'netmask', 'num_addresses', 'broadcast_address', 'first_address', 'last_address']
@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"]
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(SubnetAdmin, self).get_queryset(request)
return super().get_queryset(request)
@admin.register(Ip6Subnet)
class Ip6SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
list_display = [
"name",
"network",
"netmask",
"domain_name",
"num_addresses",
"first_address",
"last_address",
]
readonly_fields = ["domain_name"]
def _network(self, ip6subnet):
return ipaddress.IPv6Network(
f"{ip6subnet.network}::/{ip6subnet.netmask:d}", strict=False
)
@admin.display(description="# addresses")
def num_addresses(self, ip6subnet):
return self._network(ip6subnet).num_addresses
@admin.display(description="first address")
def first_address(self, ip6subnet):
return self._network(ip6subnet).network_address
@admin.display(description="last address")
def last_address(self, ip6subnet):
# subnet[-1] is O(1); never materialize hosts() for a /64.
return self._network(ip6subnet)[-1]
class DomainSrvRecordInline(admin.TabularInline):
@@ -118,29 +273,59 @@ class DomainAAAARecordInline(admin.TabularInline):
extra = 0
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(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()])
@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']
list_display = [
"name",
"version",
"arch",
"pxe_kernel",
"pxe_initrd",
"pxe_preseed_config_url",
]
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)
+23 -11
View File
@@ -1,7 +1,8 @@
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)
@@ -9,13 +10,15 @@ 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):
@@ -23,16 +26,24 @@ 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['certname']))
if host.pxe_key != attrs["pxe_key"]:
raise ValidationError(
"Supplied pxe_key '{}' does not match host '{}'.".format(
attrs["pxe_key"], attrs["certname"]
)
)
self.host = host
@@ -41,6 +52,7 @@ 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)
host = serializer.save() # sets Host.pxe_installable=False
serializer.save() # sets Host.pxe_installable=False
return Response({'valid_pxe_key': True})
return Response({"valid_pxe_key": True})
+3 -3
View File
@@ -2,9 +2,9 @@ from django.apps import AppConfig
class MdbAppConfig(AppConfig):
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
import mdb.signals # noqa
+20 -8
View File
@@ -34,7 +34,7 @@
"domain_active_serial": 0,
"domain_ip6addr": "::1",
"domain_refresh": 28800,
"created_date": "2015-10-03T13:02:53.581",
"created_date": "2015-10-03T13:02:53.581Z",
"domain_soa": "sega.neuf.no",
"domain_admin": "sysadmin@neuf.no",
"domain_minimum_ttl": 86400,
@@ -51,7 +51,7 @@
{
"fields": {
"domain": 1,
"created_date": "2015-10-04T17:13:46.810",
"created_date": "2015-10-04T17:13:46.810Z",
"name": "www",
"target": "edb.technology."
},
@@ -61,7 +61,7 @@
{
"fields": {
"domain": 1,
"created_date": "2015-10-04T17:13:46.807",
"created_date": "2015-10-04T17:13:46.807Z",
"name": "test",
"target": "127.0.0.1"
},
@@ -71,7 +71,7 @@
{
"fields": {
"domain": 1,
"created_date": "2015-10-04T17:13:46.809",
"created_date": "2015-10-04T17:13:46.809Z",
"name": "test",
"target": "::1"
},
@@ -103,7 +103,7 @@
"netmask": 64,
"domain_active_serial": 0,
"domain_refresh": 28800,
"created_date": "2015-10-04T12:50:12.248",
"created_date": "2015-10-04T12:50:12.248Z",
"domain_soa": "sega.neuf.no",
"domain_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.765",
"created_date": "2015-10-03T13:08:17.765Z",
"domain_soa": "sega.neuf.no",
"domain_admin": "sysadmin@neuf.no",
"domain_minimum_ttl": 86400,
@@ -2738,6 +2738,18 @@
"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": "",
@@ -2751,7 +2763,7 @@
"pxe_installable": true,
"kerberos_principal_created_date": null,
"location": "kak",
"created_date": "2015-10-03T13:23:37.061",
"created_date": "2015-10-03T13:23:37.061Z",
"owner": "edb",
"serial_number": "12345678",
"model": "OptiPlex 760",
@@ -2770,7 +2782,7 @@
"host": 1,
"macaddr": "f0:de:f1:58:be:1a",
"ip4address": 2,
"created_date": "2015-10-03T13:23:37.063"
"created_date": "2015-10-03T13:23:37.063Z"
},
"model": "mdb.interface",
"pk": 1
+77
View File
@@ -0,0 +1,77 @@
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
@@ -1,106 +0,0 @@
from __future__ import with_statement
import os
import sys
from difflib import context_diff
from django.core.management.base import BaseCommand
from django.core.mail import mail_admins
from optparse import make_option
try:
from subprocess import getstatusoutput
except ImportError:
from commands import getstatusoutput
from mdb.models import DhcpConfig
class Command(BaseCommand):
args = '--output <path> [--force] [--restart]'
help = 'Generates the DHCP configuration'
option_list = BaseCommand.option_list + (
make_option('--output',
action='store',
dest='output',
default=None,
help='Path to dhcpd.conf, defaults to stdout'),
make_option('--force',
action='store_true',
dest='force',
default=False,
help='Write configuration even if serial is unchanged.'),
make_option('--restart',
action='store_true',
dest='restart',
default=False,
help='Restart dhcp3-server if serial is changed'),
)
def handle(self, *args, **options):
output = options['output'] if options['output'] else sys.stdout
force = options['force']
restart = options['restart']
restart_command = "service isc-dhcp-server restart"
config = DhcpConfig.objects.filter(name="default").get()
# do we need to do anything?
if config.serial == config.active_serial and not force:
print("Nothing to do, serial is the same: %s" % config.serial)
sys.exit(0)
# write to stdout
if output == sys.stdout:
print(config.dhcpd_configuration())
print('Not writing to file, serial not updated')
return
# read current file contents
try:
with open(output, 'r') as f:
old = f.read()
except IOError:
old = ''
# write to file
new = config.dhcpd_configuration()
with open(output, 'w') as f:
f.write(new)
# calculate diff
diff = "\n".join(context_diff(
a=old.splitlines(),
b=new.splitlines(),
fromfile=config.active_serial,
tofile=config.serial,
lineterm=''))
print(diff)
# update serial
old_serial = config.active_serial
config.active_serial = config.serial
config.save()
# restart the dhcp server?
if restart:
status, output = getstatusoutput(restart_command)
if status != 0:
message = (
"Failed to restart dhcp3-server, please inspect...\n\n" +
"%s@%s# %s" % (os.getlogin(), os.uname()[1], restart_command) +
"\n%s\n\n%s" % (output, diff))
mail_admins(
subject="FAIL: dhcpd %s -> %s" % (old_serial, config.active_serial),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(1)
else:
message = (
"Updated dhcpd configuration to serial %d\n\n" % config.active_serial +
"%s" % diff)
mail_admins(
subject="Success: dhcpd %s -> %s" % (old_serial, config.active_serial),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(0)
+82
View File
@@ -0,0 +1,82 @@
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
@@ -1,175 +0,0 @@
from __future__ import with_statement
import os
import sys
from itertools import chain
from difflib import context_diff
from django.core.management.base import BaseCommand
from django.core.mail import mail_admins
from optparse import make_option
try:
from subprocess import getstatusoutput
except ImportError:
from commands import getstatusoutput
from mdb.models import Domain, Ip4Subnet, Ip6Subnet
class Command(BaseCommand):
args = '[--force] [--debug]'
help = 'Generates the bind configuration'
option_list = BaseCommand.option_list + (
make_option('--debug',
action='store_true',
dest='debug',
default=False,
help='Debug mode. Writes to /tmp and does not reload.'),
make_option('--force',
action='store_true',
dest='force',
default=False,
help='Write zone files even if serial is unchanged.'),
)
bind_bin = "/etc/init.d/bind9"
reload_command = "%s reload" % bind_bin
checkzone_bin = "/usr/sbin/named-checkzone"
checkzone_dir = "/tmp/mdb-checkzone"
debug = False
debug_dir = '/tmp/mdb-debug'
changes = {}
def handle(self, *args, **options):
self.debug = debug = options['debug']
force = options['force']
# do the binaries exist?
for f in (self.checkzone_bin, self.bind_bin):
if not os.path.isfile(f):
print("ERROR: no such file %s, exiting..." % f)
if not debug:
sys.exit(1)
# create directories if needed
os.path.isdir(self.checkzone_dir) or os.mkdir(self.checkzone_dir)
debug and (os.path.isdir(self.debug_dir) or os.mkdir(self.debug_dir))
# update zones with new serials
for zone in chain(Domain.objects.all(),
Ip4Subnet.objects.all(),
Ip6Subnet.objects.all()):
if zone.domain_serial == zone.domain_active_serial or force:
continue
if self.update_zone(zone):
self.changes[zone.domain_name]['success'] = True
# join errors, diffs and zone names
errors = "\n\n\n".join(
[v['errors'] for k, v in self.changes.items() if v['errors']])
diffs = "\n\n\n".join(
[v['diff'] for k, v in self.changes.items() if v['diff']])
zones = ", ".join(self.changes.keys())
# reload and send email with changes
if self.changes and not debug:
sys.stdout.write("reloading bind... ")
status, output = getstatusoutput(self.reload_command)
if status != 0:
sys.stdout.write("fail\n")
message = (
"Failed to reload bind, please inspect...\n\n" +
"%s@%s# %s" % (
os.getlogin(), os.uname()[1], self.reload_command) +
"\n%s\n\n\n%s\n\n\n%s" % (output, errors, diffs))
mail_admins(
subject="FAIL: bind (%s changed: %s)" % (len(self.changes), zones),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(1)
else:
sys.stdout.write("ok\n")
message = (
"Successfully updated bind zone files\n\n" +
"%s\n\n%s" % (errors, diffs))
mail_admins(
subject="Success: bind (%s changed: %s)" % (len(self.changes), zones),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(0)
def update_zone(self, zone):
zonetype = getattr(zone._meta, 'verbose_name', None) \
or zone._meta.model_name
self.changes[zone.domain_name] = {
'old_serial': zone.domain_active_serial,
'new_serial': zone.domain_serial,
'success': False,
'errors': None,
'diff': None,
}
print("updating %s %s [%d -> %d]" % (
zonetype,
zone.domain_name,
zone.domain_active_serial,
zone.domain_serial))
# generate zone file contents
new = zone.zone_file_contents()
# read current file contents
try:
with open(zone.domain_filename, 'r') as f:
old = f.read()
except IOError:
old = ''
# calculate diff
diff = "\n".join(context_diff(
a=old.splitlines(),
b=new.splitlines(),
fromfile=str(zone.domain_active_serial) + '-' + zone.domain_name,
tofile=str(zone.domain_serial) + '-' + zone.domain_name,
lineterm=''))
self.changes[zone.domain_name]['diff'] = diff
# validate with zonecheck
sys.stdout.write("\t- validating... ")
status, output = self.check_zone(zone, new)
if status != 0:
sys.stdout.write("fail\n")
self.changes[zone.domain_name]['errors'] = output
return False
sys.stdout.write("ok\n")
# write to zone file
sys.stdout.write("\t- writing zone file... ")
if not self.debug:
zonefile = zone.domain_filename
else:
zonefile = self.debug_dir + zone.domain_filename
path = "/".join(zonefile.split('/')[:-1])
os.path.isdir(path) or os.makedirs(path)
with open(zonefile, 'w') as f:
f.write(new)
# update serial
if not self.debug:
zone.domain_active_serial = zone.domain_serial
zone.save()
sys.stdout.write("ok\n")
self.changes[zone.domain_name]['success'] = True
return True
def check_zone(self, zone, contents):
zonefile = self.checkzone_dir + '/' + zone.domain_name
with open(zonefile, 'w') as f:
f.write(contents)
return getstatusoutput("%s %s %s" % (
self.checkzone_bin, zone.domain_name, zonefile))
+131
View File
@@ -0,0 +1,131 @@
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)
+16 -18
View File
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import re
import django.core.validators
@@ -81,7 +79,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')),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
],
options={
},
@@ -94,7 +92,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')),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
],
options={
},
@@ -112,7 +110,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')),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
],
options={
},
@@ -125,7 +123,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')),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
],
options={
},
@@ -174,8 +172,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')),
('host', models.ForeignKey(to='mdb.Host')),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
('host', models.ForeignKey(to='mdb.Host', on_delete=models.CASCADE)),
],
options={
},
@@ -217,7 +215,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')),
('dhcp_config', models.ForeignKey(to='mdb.DhcpConfig', on_delete=models.CASCADE)),
],
options={
'verbose_name': 'IPv4 subnet',
@@ -229,7 +227,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')),
('interface', models.ForeignKey(to='mdb.Interface', on_delete=models.CASCADE)),
],
options={
'verbose_name': 'IPv6 address',
@@ -309,7 +307,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='operatingsystem',
name='architecture',
field=models.ForeignKey(to='mdb.OsArchitecture'),
field=models.ForeignKey(to='mdb.OsArchitecture', on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AddField(
@@ -321,7 +319,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='ip6address',
name='subnet',
field=models.ForeignKey(to='mdb.Ip6Subnet'),
field=models.ForeignKey(to='mdb.Ip6Subnet', on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AddField(
@@ -333,25 +331,25 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='ip4address',
name='subnet',
field=models.ForeignKey(to='mdb.Ip4Subnet'),
field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AddField(
model_name='interface',
name='ip4address',
field=models.ForeignKey(null=True, blank=True, to='mdb.Ip4Address', unique=True),
field=models.ForeignKey(null=True, blank=True, to='mdb.Ip4Address', unique=True, on_delete=models.SET_NULL),
preserve_default=True,
),
migrations.AddField(
model_name='host',
name='host_type',
field=models.ForeignKey(to='mdb.HostType'),
field=models.ForeignKey(to='mdb.HostType', on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AddField(
model_name='host',
name='operating_system',
field=models.ForeignKey(to='mdb.OperatingSystem'),
field=models.ForeignKey(to='mdb.OperatingSystem', on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AddField(
@@ -369,13 +367,13 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='dhcpoption',
name='ip4subnet',
field=models.ForeignKey(to='mdb.Ip4Subnet'),
field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AddField(
model_name='dhcpcustomfield',
name='ip4subnet',
field=models.ForeignKey(to='mdb.Ip4Subnet'),
field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
preserve_default=True,
),
]
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.core.validators
import re
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.db.migrations import RunPython
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
+1 -3
View File
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.core.validators
import re
@@ -35,7 +33,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='interface',
name='ip4address',
field=models.OneToOneField(to='mdb.Ip4Address', null=True, blank=True),
field=models.OneToOneField(to='mdb.Ip4Address', null=True, blank=True, on_delete=models.SET_NULL),
),
migrations.AlterField(
model_name='interface',
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import re
import django.core.validators
+1 -3
View File
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
@@ -18,7 +16,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')),
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
],
),
migrations.AlterField(
+28
View File
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mdb', '0010_auto_20151004_1709'),
]
operations = [
migrations.AddField(
model_name='operatingsystem',
name='pxe_initrd',
field=models.CharField(null=True, blank=True, max_length=255),
),
migrations.AddField(
model_name='operatingsystem',
name='pxe_kernel',
field=models.CharField(null=True, blank=True, max_length=255),
),
migrations.AddField(
model_name='operatingsystem',
name='pxe_preseed_config_url',
field=models.URLField(null=True, blank=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.1.13 on 2025-05-12 03:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mdb', '0011_auto_20160325_1412'),
]
operations = [
migrations.AlterField(
model_name='domaintxtrecord',
name='target',
field=models.CharField(max_length=2048),
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-05-20 21:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mdb', '0012_alter_domaintxtrecord_target'),
]
operations = [
migrations.AddField(
model_name='host',
name='allow_email',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,39 @@
import ipaddress
from django.db import migrations
def set_classless_reverse_names(apps, schema_editor):
"""Recompute domain_name for sub-/24 IPv4 subnets (RFC 2317 classless).
domain_name is frozen on first save, so subnets created before classless
support kept the plain /24 name (190.36.158.in-addr.arpa) even for a /26.
That apex no longer matches the classless zone declared in named.conf, so
BIND refuses to load the file. Rewrite those names to the RFC 2317 form
(192-26.190.36.158.in-addr.arpa). /24-or-shorter subnets are left alone.
"""
Ip4Subnet = apps.get_model("mdb", "Ip4Subnet")
for subnet in Ip4Subnet.objects.all():
try:
net = ipaddress.IPv4Network(
"%s/%s" % (subnet.network, subnet.netmask), strict=False
)
except ValueError:
continue
if net.prefixlen <= 24:
continue
o1, o2, o3, o4 = str(net.network_address).split(".")
name = "%s-%d.%s.%s.%s.in-addr.arpa" % (o4, net.prefixlen, o3, o2, o1)
if subnet.domain_name != name:
subnet.domain_name = name
subnet.save(update_fields=["domain_name"])
class Migration(migrations.Migration):
dependencies = [
("mdb", "0013_host_allow_email"),
]
operations = [
migrations.RunPython(set_classless_reverse_names, migrations.RunPython.noop),
]
+200 -235
View File
@@ -1,16 +1,13 @@
from django.db import models
from django.db.models import Count
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
import datetime
import ipaddress
from django.db import models
from django.utils.translation import gettext_lazy as _
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)
@@ -18,7 +15,6 @@ class Nameserver(models.Model):
return self.hostname
@python_2_unicode_compatible
class MailExchange(models.Model):
priority = models.IntegerField()
hostname = models.CharField(max_length=256)
@@ -27,7 +23,6 @@ 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)
@@ -39,8 +34,10 @@ 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)
@@ -50,51 +47,40 @@ class Domain(models.Model):
def __str__(self):
return self.domain_name
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 = "; 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" % (
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(
self.domain_soa,
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
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"
content += ";\n"
for nameserver in self.domain_nameservers.all():
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
for mx in self.domain_mailexchanges.all():
content += "@\t\tMX\t%d %s.\n" % (mx.priority, mx.hostname)
content += f"@\t\tMX\t{mx.priority:d} {mx.hostname}.\n"
if self.domain_ipaddr is not None:
content += "@\tIN\tA\t%s\n" % self.domain_ipaddr
content += f"@\tIN\tA\t{self.domain_ipaddr}\n"
if self.domain_ip6addr is not None:
content += "@\tIN\tAAAA\t%s\n" % self.domain_ip6addr
if self.domain_ip6addr:
content += f"@\tIN\tAAAA\t{self.domain_ip6addr}\n"
content += "; SRV records\n"
@@ -114,7 +100,7 @@ class Domain(models.Model):
content += "; CNAME records \n"
for cname in self.domaincnamerecord_set.all():
content += "%s\tIN\tCNAME\t%s\n" % (cname.name, cname.target)
content += f"{cname.name}\tIN\tCNAME\t{cname.target}\n"
content += "; TXT records \n"
@@ -126,18 +112,21 @@ class Domain(models.Model):
for interface in self.interface_set.all():
host = interface.host
if interface.ip4address:
content += "%-20s\tIN\tA\t%s\n" % (
host.hostname,
interface.ip4address.address)
content += (
f"{host.hostname:<20}\tIN\tA\t{interface.ip4address.address}\n"
)
for ipv6addr in interface.ip6address_set.all():
content += "%-20s\tIN\tAAAA\t%s\n" % (
host.hostname,
ipv6addr.full_address())
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'
return content
@python_2_unicode_compatible
class DomainSrvRecord(models.Model):
srvce = models.CharField(max_length=64)
prot = models.CharField(max_length=64)
@@ -146,82 +135,71 @@ class DomainSrvRecord(models.Model):
weight = models.IntegerField()
port = models.IntegerField()
target = models.CharField(max_length=256)
domain = models.ForeignKey(Domain)
domain = models.ForeignKey(Domain, models.CASCADE)
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return '{}.{}.{}. IN SRV {} {} {} {}.'.format(
self.srvce,
self.prot,
self.name,
self.priority,
self.weight,
self.port,
self.target
)
return f"{self.srvce}.{self.prot}.{self.name}. IN SRV {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)
target = models.CharField(max_length=256)
domain = models.ForeignKey(Domain)
# 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)
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return '{} TXT {}'.format(self.name, self.target)
return f"{self.name} TXT {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)
domain = models.ForeignKey(Domain, models.CASCADE)
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return '{} IN CNAME {}'.format(self.name, self.target)
return f"{self.name} IN CNAME {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)
target = models.GenericIPAddressField(protocol="IPv4")
domain = models.ForeignKey(Domain, models.CASCADE)
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return '{} IN A {}'.format(self.name, self.target)
return f"{self.name} IN A {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)
target = models.GenericIPAddressField(protocol="IPv6")
domain = models.ForeignKey(Domain, models.CASCADE)
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return '{} IN AAAA {}'.format(self.name, self.target)
return f"{self.name} IN AAAA {self.target}"
def __str__(self):
return self.as_record()
@python_2_unicode_compatible
class DhcpConfig(models.Model):
serial = models.IntegerField()
active_serial = models.IntegerField()
@@ -233,45 +211,48 @@ class DhcpConfig(models.Model):
log_facility = models.CharField(max_length=255)
def dhcpd_configuration(self):
content = "# Autogenerated configuration %s\n" % datetime.datetime.now()
content = f"# Autogenerated configuration {datetime.datetime.now()}\n"
if self.authoritative:
content += "authoritative;\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
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"
# time to write the subnet definitions
for subnet in self.ip4subnet_set.all():
content += "\n# %s\n" % subnet.name
content += "subnet %s netmask %s {\n" % (
subnet.network,
subnet.netmask)
content += f"\n# {subnet.name}\n"
content += f"subnet {subnet.network} netmask {subnet.netmask} {{\n"
for option in subnet.dhcpoption_set.all():
content += "\toption %s %s;\n" % (option.key, option.value)
content += f"\toption {option.key} {option.value};\n"
for option in subnet.dhcpcustomfield_set.all():
content += "\t%s;\n" % option.value
content += f"\t{option.value};\n"
if subnet.dhcp_dynamic:
content += "\trange %s %s;\n" \
% (subnet.dhcp_dynamic_start, subnet.dhcp_dynamic_end)
content += (
f"\trange {subnet.dhcp_dynamic_start} {subnet.dhcp_dynamic_end};\n"
)
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 += "\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)
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"
)
if len(_if.pxe_filename) > 0:
content += "\tfilename \"%s\";\n" % _if.pxe_filename
content += f'\tfilename "{_if.pxe_filename}";\n'
content += "}\n"
return content
@@ -280,10 +261,9 @@ 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)
@@ -305,25 +285,38 @@ 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 += "; 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" % (
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(
self.domain_soa,
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
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"
content += ";\n"
content += ";\n"
for nameserver in self.domain_nameservers.all():
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
content += ";\n"
@@ -332,17 +325,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 = "%s.%s" % (
addr.interface.host.hostname,
addr.interface.domain.domain_name)
hostname = (
f"{addr.interface.host.hostname}.{addr.interface.domain.domain_name}"
)
ip = ipaddress.IPv6Address(self.network + addr.address)
ip = ".".join(ip.exploded.replace(":", "")[16:])[::-1]
content += "%s\tPTR\t%s.\n" % (ip, hostname)
content += f"{ip}\tPTR\t{hostname}.\n"
# addr = ipaddr.IPv6Address("%s%s" % (interface.
# content += "%20s\tIN\tPTR\t%s.\n" % (addr.address.split(".")[3], hostname)
@@ -350,14 +343,13 @@ 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)
@@ -373,122 +365,109 @@ 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)
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)
def __str__(self):
return self.network + " (" + self.name + ")"
def num_addresses(self):
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask)
return subnet.num_addresses
@property
def reverse_zone_name(self):
"""The in-addr.arpa reverse zone name for this subnet.
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'
Masks of /24 or shorter give the standard octet-boundary name
(190.36.158.in-addr.arpa). Masks longer than /24 give the RFC 2317
classless name for the sub-/24 block (192-26.190.36.158.in-addr.arpa).
This is the apex BIND expects -- it must match the zone name in
named.conf or rndc refuses to load the file.
"""
net = ipaddress.IPv4Network(f"{self.network}/{self.netmask}", strict=False)
o1, o2, o3, o4 = str(net.network_address).split(".")
rev = f"{o3}.{o2}.{o1}"
if net.prefixlen > 24:
return f"{o4}-{net.prefixlen:d}.{rev}.in-addr.arpa"
return f"{rev}.in-addr.arpa"
def zone_file_contents(self, generate_unassigned=False):
content = ""
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" % (
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(
self.domain_soa,
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
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"
content += ";\n"
content += ";\n"
for nameserver in self.domain_nameservers.all():
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
content += ";\n"
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 = "%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" % (
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(
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)
ip4subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
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)
ip4subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
class Meta:
verbose_name = 'DHCP custom field'
verbose_name = "DHCP custom field"
@python_2_unicode_compatible
class Ip4Address(models.Model):
subnet = models.ForeignKey(Ip4Subnet)
address = models.GenericIPAddressField(protocol='IPv4')
subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
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):
# 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"
return self.address
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)
@@ -503,22 +482,27 @@ 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)
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)
def __str__(self):
return self.name + " " + self.version + " (" + self.arch + ")"
@@ -527,7 +511,6 @@ 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)
@@ -537,14 +520,19 @@ 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)
host_type = models.ForeignKey(HostType, models.CASCADE) # FIXME: SET_NULL?
virtual = models.BooleanField(default=False)
operating_system = models.ForeignKey(OperatingSystem)
allow_email = models.BooleanField(default=False)
operating_system = models.ForeignKey(
OperatingSystem, models.CASCADE
) # FIXME: SET_NULL?
request_kerberos_principal = models.BooleanField(default=False)
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)
@@ -552,63 +540,40 @@ 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)
ip4address = models.OneToOneField(Ip4Address, blank=True, null=True)
host = models.ForeignKey(Host, models.CASCADE)
ip4address = models.OneToOneField(
Ip4Address, blank=True, null=True, on_delete=models.SET_NULL
)
created_date = models.DateTimeField(auto_now_add=True)
domain = models.ForeignKey(Domain)
domain = models.ForeignKey(Domain, models.CASCADE)
def __str__(self):
return "%s (%s on %s)" % (self.macaddr, self.name, self.host.hostname)
return f"{self.macaddr} ({self.name} on {self.host.hostname})"
def ipv6_enabled(self):
return self.ip6address_set.exists()
@python_2_unicode_compatible
class Ip6Address(models.Model):
subnet = models.ForeignKey(Ip6Subnet)
address = models.GenericIPAddressField(protocol='IPv6')
interface = models.ForeignKey(Interface)
subnet = models.ForeignKey(Ip6Subnet, models.CASCADE)
address = models.GenericIPAddressField(protocol="IPv6")
interface = models.ForeignKey(Interface, models.CASCADE)
def full_address(self):
return self.subnet.network + self.address
def __str__(self):
return "%s (%s on %s)" % (
self.full_address(),
self.interface.name,
self.interface.host.hostname)
return f"{self.full_address()} ({self.interface.name} on {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
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,59 @@
from difflib import context_diff
from subprocess import getstatusoutput
from mdb.models import DhcpConfig
class DhcpSyncService:
restart_command = "/usr/sbin/service isc-dhcp-server restart"
def __init__(self, force=False):
self.force = force
def get_config(self):
return DhcpConfig.objects.filter(name="default").get()
def needs_update(self, config):
return config.serial != config.active_serial or self.force
def calculate_diff(self, config, new, old):
return "\n".join(
context_diff(
a=old.splitlines(),
b=new.splitlines(),
fromfile=str(config.active_serial),
tofile=str(config.serial),
lineterm="",
)
)
def write_config(self, config, output_path):
"""Write new DHCP config to file and update active_serial.
Returns (old_serial, diff).
"""
try:
with open(output_path) as f:
old = f.read()
except OSError:
old = ""
new = config.dhcpd_configuration()
with open(output_path, "w") as f:
f.write(new)
diff = self.calculate_diff(config, new, old)
old_serial = config.active_serial
config.active_serial = config.serial
config.save()
return old_serial, diff
def restart_service(self):
"""Run the DHCP server restart command.
Returns (success, output).
"""
status, output = getstatusoutput(self.restart_command)
return status == 0, output
+45
View File
@@ -0,0 +1,45 @@
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
@@ -0,0 +1,183 @@
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}")
+28 -18
View File
@@ -1,12 +1,13 @@
import ipaddress
import logging
import os
import uuid
from django.conf import settings
from django.db.models.signals import pre_delete, post_save, pre_save
from django.dispatch import receiver
import ipaddress
from mdb.models import Interface, Host, Ip6Subnet, Ip4Subnet, Ip4Address
from django.conf import settings
from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
from mdb.models import Host, Interface, Ip4Address, Ip4Subnet, Ip6Subnet
from mdb.utils import format_domain_serial_and_add_one
logger = logging.getLogger(__name__)
@@ -34,9 +35,7 @@ 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:
ipspl = instance.network.split(".")
rev = "%s.%s.%s" % (ipspl[2], ipspl[1], ipspl[0])
instance.domain_name = "%s.in-addr.arpa" % rev
instance.domain_name = instance.reverse_zone_name
# update it's own serial
# if instance.domain_serial is not None:
@@ -46,7 +45,9 @@ 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()
@@ -55,8 +56,7 @@ def set_domain_name_for_ipv6_subnet(sender, instance, **kwargs):
if len(instance.domain_name) > 0:
return
network = ipaddress.IPv6Address("%s::" % instance.network)
instance.domain_name = ".".join(network.exploded.replace(":", "")[:16])[::-1] + ".ip6.arpa"
instance.domain_name = instance.reverse_zone_name
@receiver(post_save, sender=Interface)
@@ -77,16 +77,22 @@ 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:
@@ -98,9 +104,11 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(sender, instance, creat
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:
with open(path, 'w+') as f:
# Write pxeconfig file to disk
with open(path, "w+") as f:
f.write(pxe_file)
logger.info("Created or updated {}".format(path))
logger.info(f"Created or updated {path}")
os.chmod(path, 0o644) # Not writeable for all
# Also set necessary fields for pxe installation
interfaces = host.interface_set.exclude(ip4address__isnull=True)
@@ -108,7 +116,7 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(sender, instance, creat
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
@@ -119,8 +127,9 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(sender, instance, creat
else:
if os.path.exists(path):
# Delete pxeconfig file from disk
os.unlink(path)
logger.info("deleted {}".format(path))
logger.info(f"deleted {path}")
@receiver(pre_delete, sender=Interface)
@@ -135,6 +144,7 @@ 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)
+6 -3
View File
@@ -1,4 +1,7 @@
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}}
default ninja
prompt 0
timeout 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
-56
View File
@@ -1,56 +0,0 @@
import io
from django.contrib.auth.models import User
from django.core.management import call_command
from django.core.urlresolvers import reverse
from django.test import TestCase
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
from mdb.models import Host
class MyAPITestCases(APITestCase):
fixtures = ['test_data']
def create_user_with_token(self):
self.user = User.objects.create(username='admin')
self.token = Token.objects.create(user=self.user)
self.host = Host.objects.first()
def setUp(self):
self.create_user_with_token()
return super(MyAPITestCases, self).setUp()
def test_validate_puppet_host_by_secret(self):
data = {
'pxe_key': self.host.pxe_key,
'certname': '{}.{}'.format(self.host.hostname, self.host.interface_set.first().domain.domain_name)
}
self.client.credentials(HTTP_AUTHORIZATION='Token {}'.format(self.token.key))
res = self.client.post(reverse('api-validate-host-secret'), data, format='json')
self.assertEquals(res.status_code, 200, res.content)
self.assertFalse(Host.objects.get(pk=self.host.pk).pxe_installable)
class UnitTests(TestCase):
fixtures = ['test_data']
def test_host_as_pxe_files(self):
host = Host.objects.first()
self.assertEqual(len(host.as_pxe_files()), 1)
class RunManagementCommands(TestCase):
fixtures = ['test_data']
def test_zone_sync(self):
expected_zone_file = ''
with io.BytesIO() as f:
call_command('zone-sync', debug=True, stdout=f)
# TODO check expected_zone_file
def test_dhcp_sync(self):
expected_dhcp_file = ''
with io.BytesIO() as f:
call_command('dhcp-sync', stdout=f)
# TODO check expected_dhcp_file
+9 -56
View File
@@ -1,26 +1,25 @@
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("^%4d%02d%02d(\d\d)$" % (
today.year, today.month, today.day), str(serial), re.DOTALL)
res = re.findall(
rf"^{today.year:4d}{today.month:02d}{today.day:02d}(\d\d)$",
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 "%4d%02d%02d%02d" % \
(today.year, today.month, today.day, 1)
return f"{today.year:4d}{today.month:02d}{today.day:02d}01"
elif len(res) == 1:
""" The serial contains todays date, just update it. """
try:
number = int(res[0])
except:
except ValueError:
number = 1
if number >= 99:
""" This is bad... Just keep the number on 99.
@@ -29,53 +28,7 @@ def format_domain_serial_and_add_one(serial):
else:
number += 1
return "%4d%02d%02d%02d" % (
today.year, today.month, today.day, number)
return f"{today.year:4d}{today.month:02d}{today.day:02d}{number:02d}"
else:
""" Just return the first serial for today. """
return "%4d%02d%02d%02d" % \
(today.year, today.month, today.day, 1)
def render_pxelinux_cfg(_if, host):
# TODO add fields to os model and make dynamic per host
distro = 'ubuntu/14_04/amd64/alternate/'
kernel = distro + 'linux'
initrd = distro + 'initrd.gz'
preseed_config_url = settings.MDB_PXE_PRESEED_URL
context = {
'kernel': kernel,
'initrd': initrd,
'preseed_config_url': preseed_config_url,
'host': host,
'interface': _if
}
return render_to_string('pxelinux.cfg', context=context)
def get_pxe_filename(interface, filename_format='mac_addr'):
"""
Mac or IP address based PXE filename
Ref: http://www.syslinux.org/wiki/index.php/PXELINUX#Examples
"""
ip_addr_in_hex = '{:02X}'.format(int(ipaddress.IPv4Address(interface.ip4address.address)))
formats = {
'ip_addr': ip_addr_in_hex,
'mac_addr': '01-{}'.format(interface.macaddr.lower().replace(':', '-'))
}
return formats.get(filename_format, formats['mac_addr'])
def host_as_pxe_files(host):
""" Returns a list of filename,content for each host interface with an IPv4 address """
from mdb.models import Host
assert isinstance(host, Host)
pxe_files = []
for _if in host.interface_set.exclude(ip4address__isnull=True):
filename = get_pxe_filename(_if)
content = render_pxelinux_cfg(_if, host)
pxe_files.append((filename, content))
return pxe_files
return f"{today.year:4d}{today.month:02d}{today.day:02d}01"
+10 -12
View File
@@ -1,16 +1,14 @@
from __future__ import unicode_literals
from django.core.validators import RegexValidator
import re
# http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
hostname_re = re.compile(r'^(?!-)[-a-z0-9]+(?<!-)$', re.IGNORECASE)
validate_hostname = RegexValidator(
regex=hostname_re,
message='Enter a valid hostname',
code='invalid')
from django.core.validators import RegexValidator
macaddr_re = re.compile(r'^([0-9A-F]{2}[:]){5}([0-9A-F]{2})$', re.IGNORECASE)
# http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
hostname_re = re.compile(r"^(?!-)[-a-z0-9]+(?<!-)$", re.IGNORECASE)
validate_hostname = RegexValidator(
regex=hostname_re, message="Enter a valid hostname", code="invalid"
)
macaddr_re = re.compile(r"^([0-9A-F]{2}[:]){5}([0-9A-F]{2})$", re.IGNORECASE)
validate_macaddr = RegexValidator(
regex=macaddr_re,
message='Enter a valid MAC address',
code='invalid')
regex=macaddr_re, message="Enter a valid MAC address", code="invalid"
)
+114 -95
View File
@@ -1,131 +1,150 @@
import os
from django.conf.global_settings import TEMPLATE_CONTEXT_PROCESSORS as TCP
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
DEBUG = True
TEMPLATE_DEBUG = DEBUG
# Off unless DJANGO_DEBUG says otherwise; mise.toml turns it on for development.
DEBUG = os.getenv("DJANGO_DEBUG", "").lower() in ("1", "true", "yes")
ADMINS = (
('System Administrator', 'sysadmin@example.com'),
)
ADMINS = ("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
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
ADMIN_MEDIA_PREFIX = '/media/'
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATIC_URL = '/static/'
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/media/"
ADMIN_MEDIA_PREFIX = "/media/"
# Additional locations of static files
STATICFILES_DIRS = (
)
# List of finder classes that know how to find static files in
# various locations.
# Static
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
STATIC_URL = "/static/"
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# 'django.contrib.staticfiles.finders.DefaultStorageFinder',
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
)
# Make this unique, and don't share it with anybody.
SECRET_KEY = '@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl'
# For development only: the repository is public. Production sets its own (MDB_SETTINGS).
SECRET_KEY = "@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl"
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',
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",
)
ROOT_URLCONF = 'mdbsite.urls'
ROOT_URLCONF = "mdbsite.urls"
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.
)
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",
],
},
},
]
INSTALLED_APPS = (
'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',
"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",
)
EMAIL_SUBJECT_PREFIX = '[MDB] '
EMAIL_SUBJECT_PREFIX = "[MDB] "
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')
# Logging
LOGGING = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"verbose": {
"format": "%(levelname)s %(asctime)s %(module)s "
"%(process)d %(thread)d %(message)s"
},
),
# misc
'LIST_PER_PAGE': 30
},
"handlers": {
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose",
}
},
"loggers": {
"root": {
"level": "WARNING",
"handlers": ["console"],
},
"django.db.backends": {
"level": "ERROR",
"handlers": ["console"],
"propagate": False,
},
},
}
# REST Framework
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
'PAGINATE_BY': 10
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"PAGINATE_BY": 10,
}
MDB_PXE_TFTP_ROOT = '/var/lib/tftpboot/pxelinux/pxelinux.cfg/'
MDB_PXE_PRESEED_URL = 'http://158.36.190.194/ubuntu/preseed_1404.cfg'
# 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"
try:
from .local_settings import *
except ImportError:
pass
# 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
+9 -4
View File
@@ -1,15 +1,20 @@
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 = [
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')
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",
),
]
urlpatterns += staticfiles_urlpatterns()
+3 -1
View File
@@ -1,5 +1,7 @@
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
@@ -0,0 +1,7 @@
[tools]
python = "3.14"
uv = "latest"
[env]
# settings.py leaves DEBUG off unless this says otherwise.
DJANGO_DEBUG = "1"
+32
View File
@@ -0,0 +1,32 @@
# Git hooks, run by prek (https://prek.j178.dev). `prek install` once per clone; CI runs
# them all (.gitea/workflows/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
@@ -0,0 +1,58 @@
[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
@@ -1,7 +0,0 @@
Django>=1.8,<1.9
django-auth-ldap
django-extensions==1.5.7
djangorestframework==3.2.4
django-suit==0.2.15
ipaddress==1.0.14
mysqlclient==1.3.6
-8
View File
@@ -1,8 +0,0 @@
To use this scripts, you have to add the projects location to your environment
pythonpath. This is easily archieved by setting an environment variable
like this:
export PYTHONPATH=/opt/projects/:/opt/projects/dns_mdb
It is important that both the path above the project and the project
folder itself are added to the path before the script is run.
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
TMP1=$(bash -c "cd .. ; pwd ; echo \":\" ; cd .. ; pwd")
TMP2=$(echo $TMP1 | sed -e "s/ //g")
echo "Run the following to setup the python environment."
echo "export PYTHONPATH=$TMP2"
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python
# coding: utf-8
import os,sys,datetime,argparse,threading,time,Queue
from commands import getstatusoutput
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
parser.add_argument('-t', '--type', default='all',\
help="Which type of host to operate on")
parser.add_argument('-d', '--debug', action='store_true',\
help="Turn on debugging")
parser.add_argument('--show-types', dest='show_types', action='store_true',\
help='Show available host types')
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
help='Number of threads used for pinging hosts')
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
args = parser.parse_args()
#project_path = '/opt/django/dns_mdb/'
#if project_path not in sys.path:
# sys.path.append(project_path)
from django.core.management import setup_environ
from django.core.mail import mail_admins
from dns_mdb import settings
setup_environ(settings)
from mdb.models import *
PING_CMD='ping -W 1 -c %d -n -q %s | grep rtt | cut -d " " -f4'
queue = Queue.Queue()
class PingThread(threading.Thread):
""" Threaded ping """
def __init__(self, host_queue):
threading.Thread.__init__(self)
self.queue = queue
def run(self):
while True:
host = self.queue.get()
self.do_ping_host(host)
self.queue.task_done()
def do_ping_host(self, host):
interfaces = host.interface_set.all()
for interface in interfaces:
self.do_ping_interface(interface, host)
def do_ping_interface(self, interface, host):
res = self.do_ping_ipaddr(interface.ip4address.address)
if res == None:
if args.debug:
print "%s (%s/%s) : fail " % (host, \
interface.name, interface.ip4address.address)
return
interface.ip4address.last_contact = datetime.datetime.now()
interface.ip4address.ping_avg_rtt = float(res[1])
interface.ip4address.save()
if args.debug:
print "%s (%s/%s) : %s" % (host, interface.name, \
interface.ip4address.address, res)
def do_ping_ipaddr(self, ipaddr):
status, output = getstatusoutput(PING_CMD % (args.num_pings,ipaddr))
if status == 0 and len(output) > 0:
return output.split("/")
else:
return None
def show_host_types():
types = HostType.objects.all()
for t in types:
print "%s (%s)" % (t.host_type, t.description)
if args.show_types:
show_host_types()
sys.exit(0)
if args.type == "all":
hosts = Host.objects.all()
else:
hosts = Host.objects.filter(host_type__host_type = args.type)
# start num_threads threads for pinging
for i in xrange(args.num_threads):
t = PingThread(queue)
t.setDaemon(True)
t.start()
if args.debug:
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
# Add all hosts to the queue. Ping threads
# will pick hosts from this queue.
for host in hosts:
queue.put(host)
# Wait for all threads to finish, eg. queue is empty.
queue.join()
-156
View File
@@ -1,156 +0,0 @@
#!/usr/bin/env python
# coding: utf-8
import os,sys,datetime,argparse,threading,time,Queue
from commands import getstatusoutput
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
parser.add_argument('-t', '--type', default='all',\
help="Which type of host to operate on")
parser.add_argument('-d', '--debug', action='store_true',\
help="Turn on debugging")
parser.add_argument('--show-types', dest='show_types', action='store_true',\
help='Show available host types')
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
help='Number of threads used for pinging hosts')
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
parser.add_argument('--rrd-path', dest='rrd_path', required=True,\
help='Specify the path to the rrd files')
parser.add_argument('--graph-path', dest='graph_path', required=True,\
help="Specify the output directory for the graphs")
args = parser.parse_args()
from django.core.management import setup_environ
from django.core.mail import mail_admins
from dns_mdb import settings
setup_environ(settings)
from mdb.models import *
queue = Queue.Queue()
RRD_GRAPH = "%s " \
"-w 785 -h 120 -a PNG " \
"--slope-mode " \
"--start -86400 --end now " \
"--font DEFAULT:7: "\
"--title \"%s\" " \
"--watermark \"NEUF.NO\" " \
"--vertical-label \"latency(ms)\" " \
"--right-axis-label \"latency(ms)\" " \
"--lower-limit 0 " \
"--right-axis 1:0 " \
"--x-grid MINUTE:10:HOUR:1:MINUTE:120:0:%%R " \
"--alt-y-grid --rigid " \
"DEF:roundtrip=%s:rrd:AVERAGE " \
"LINE1:roundtrip#0000FF:\"latency(ms)\" "
# RRD_GRAPH ARGS: <output_filename.png>, <title>, <rrd>
RRD_DIR = args.rrd_path
RRD_GRAPH_DIR = args.graph_path
RRDTOOL = "/usr/bin/rrdtool"
class RrdService():
def log(self, host, interface, avg_ping):
rrd = self.get_rrd(host, interface)
self.update_rrd(rrd, avg_ping)
def get_rrd(self, host, interface):
return "%s/%s_%s.rrd" % ( RRD_DIR, host.id,\
interface.id)
def get_png(self, host, interface):
return "%s/%s_%s.png" % (RRD_GRAPH_DIR, host.id,\
interface.id)
def update_rrd(self, rrd, avg_ping):
self.create_rrd(rrd)
self.rrd_exec("update", RRD_UPDATE % (rrd, avg_ping))
def create_rrd(self, rrd):
if os.path.isfile(rrd):
return True
if args.debug:
print "Creating rrd: " + rrd
if not self.rrd_exec("create", RRD_CREATE % rrd):
if args.debug:
print "Could not create RRD database."
return False
return True
def create_title(self, host, interface):
# return "%s (%s)" % (host.hostname, unicode(interface))
return unicode(interface)
def create_graph(self, host, interface):
rrd = self.get_rrd(host, interface)
if not os.path.isfile(rrd): return
self.rrd_exec("graph", RRD_GRAPH % (self.get_png(host, interface), \
self.create_title(host, interface), rrd))
def rrd_exec(self, function, rrd_cmd):
cmd = "%s %s %s" % (RRDTOOL, function, rrd_cmd)
if args.debug:
print "RRD_EXEC (%s)" % cmd
if os.system(cmd) != 0:
return False
else:
return True
class WorkerThread(threading.Thread):
""" Threaded ping """
def __init__(self, host_queue, rrd_service):
threading.Thread.__init__(self)
self.queue = queue
self.rrd_service = rrd_service
def run(self):
while True:
host = self.queue.get()
self.do_graph_host(host)
self.queue.task_done()
def do_graph_host(self, host):
interfaces = host.interface_set.all()
for interface in interfaces:
self.rrd_service.create_graph(host, interface)
def show_host_types():
types = HostType.objects.all()
for t in types:
print "%s (%s)" % (t.host_type, t.description)
if args.show_types:
show_host_types()
sys.exit(0)
if args.type == "all":
hosts = Host.objects.all()
else:
hosts = Host.objects.filter(host_type__host_type = args.type)
rrd_service = RrdService()
# start num_threads threads for pinging
for i in xrange(args.num_threads):
t = WorkerThread(queue, rrd_service)
t.setDaemon(True)
t.start()
if args.debug:
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
# Add all hosts to the queue. Ping threads
# will pick hosts from this queue.
for host in hosts:
queue.put(host)
# Wait for all threads to finish, eg. queue is empty.
queue.join()
sys.exit(0)
-151
View File
@@ -1,151 +0,0 @@
#!/usr/bin/env python
# coding: utf-8
import os,sys,datetime,argparse,threading,time,Queue
from commands import getstatusoutput
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
parser.add_argument('-t', '--type', default='all',\
help="Which type of host to operate on")
parser.add_argument('-d', '--debug', action='store_true',\
help="Turn on debugging")
parser.add_argument('--show-types', dest='show_types', action='store_true',\
help='Show available host types')
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
help='Number of threads used for pinging hosts')
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
parser.add_argument('--rrd-path', dest='rrd_path', required=True,\
help='Specify the path to the rrd files')
args = parser.parse_args()
from django.core.management import setup_environ
from django.core.mail import mail_admins
from dns_mdb import settings
setup_environ(settings)
from mdb.models import *
PING_CMD='ping -W 1 -c %d -n -q %s | grep rtt | cut -d " " -f4'
queue = Queue.Queue()
RRD_CREATE = "%s " \
"DS:ttl:GAUGE:600:U:U "\
"RRA:AVERAGE:0.5:1:576 "\
"RRA:AVERAGE:0.5:6:336 "\
"RRA:AVERAGE:0.5:72:124 "\
"RRA:AVERAGE:0.5:288:365"
RRD_UPDATE = "%s --template rtt N:%s"
RRD_DIR = args.rrd_path
RRDTOOL = "/usr/bin/rrdtool"
class RrdService():
def log(self, host, interface, avg_ping):
rrd = self.get_rrd(host, interface)
self.update_rrd(rrd, avg_ping)
def get_rrd(self, host, interface):
return "%s/%s_%s.rrd" % ( RRD_DIR, host.id,\
interface.id)
def update_rrd(self, rrd, avg_ping):
self.create_rrd(rrd)
self.rrd_exec("update", RRD_UPDATE % (rrd, avg_ping))
def create_rrd(self, rrd):
if os.path.isfile(rrd):
return True
if args.debug:
print "Creating rrd: " + rrd
if not self.rrd_exec("create", RRD_CREATE % rrd):
if args.debug:
print "Could not create RRD database."
return False
return True
def rrd_exec(self, function, rrd_cmd):
cmd = "%s %s %s" % (RRDTOOL, function, rrd_cmd)
if args.debug:
print "RRD_EXEC (%s)" % cmd
if os.system(cmd) != 0:
return False
else:
return True
class PingThread(threading.Thread):
""" Threaded ping """
def __init__(self, host_queue, rrd_service):
threading.Thread.__init__(self)
self.queue = queue
self.rrd_service = rrd_service
def run(self):
while True:
host = self.queue.get()
self.do_ping_host(host)
self.queue.task_done()
def do_ping_host(self, host):
interfaces = host.interface_set.all()
for interface in interfaces:
self.do_ping_interface(interface, host)
def do_ping_interface(self, interface, host):
res = self.do_ping_ipaddr(interface.ip4address.address)
if res == None:
if args.debug:
print "%s (%s/%s) : fail " % (host, \
interface.name, interface.ip4address.address)
return
if args.debug:
print "%s (%s/%s) : %s" % (host, interface.name, \
interface.ip4address.address, res)
self.rrd_service.log(host,interface, res[1])
def do_ping_ipaddr(self, ipaddr):
status, output = getstatusoutput(PING_CMD % (args.num_pings,ipaddr))
if status == 0 and len(output) > 0:
return output.split("/")
else:
return None
def show_host_types():
types = HostType.objects.all()
for t in types:
print "%s (%s)" % (t.host_type, t.description)
if args.show_types:
show_host_types()
sys.exit(0)
if args.type == "all":
hosts = Host.objects.all()
else:
hosts = Host.objects.filter(host_type__host_type = args.type)
rrd_service = RrdService()
# start num_threads threads for pinging
for i in xrange(args.num_threads):
t = PingThread(queue, rrd_service)
t.setDaemon(True)
t.start()
if args.debug:
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
# Add all hosts to the queue. Ping threads
# will pick hosts from this queue.
for host in hosts:
queue.put(host)
# Wait for all threads to finish, eg. queue is empty.
queue.join()
sys.exit(0)
-567
View File
@@ -1,567 +0,0 @@
/*!
* Bootstrap Responsive v2.0.0
*
* Copyright 2012 Twitter, Inc
* Licensed under the Apache License v2.0
* http://www.apache.org/licenses/LICENSE-2.0
*
* Designed and built with all the love in the world @twitter by @mdo and @fat.
*/
.hidden {
display: none;
visibility: hidden;
}
@media (max-width: 480px) {
.nav-collapse {
-webkit-transform: translate3d(0, 0, 0);
}
.page-header h1 small {
display: block;
line-height: 18px;
}
input[class*="span"],
select[class*="span"],
textarea[class*="span"],
.uneditable-input {
display: block;
width: 100%;
height: 28px;
/* Make inputs at least the height of their button counterpart */
/* Makes inputs behave like true block-level elements */
-webkit-box-sizing: border-box;
/* Older Webkit */
-moz-box-sizing: border-box;
/* Older FF */
-ms-box-sizing: border-box;
/* IE8 */
box-sizing: border-box;
/* CSS3 spec*/
}
.input-prepend input[class*="span"], .input-append input[class*="span"] {
width: auto;
}
input[type="checkbox"], input[type="radio"] {
border: 1px solid #ccc;
}
.form-horizontal .control-group > label {
float: none;
width: auto;
padding-top: 0;
text-align: left;
}
.form-horizontal .controls {
margin-left: 0;
}
.form-horizontal .control-list {
padding-top: 0;
}
.form-horizontal .form-actions {
padding-left: 10px;
padding-right: 10px;
}
.modal {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
width: auto;
margin: 0;
}
.modal.fade.in {
top: auto;
}
.modal-header .close {
padding: 10px;
margin: -10px;
}
.carousel-caption {
position: static;
}
}
@media (max-width: 768px) {
.container {
width: auto;
padding: 0 20px;
}
.row-fluid {
width: 100%;
}
.row {
margin-left: 0;
}
.row > [class*="span"], .row-fluid > [class*="span"] {
float: none;
display: block;
width: auto;
margin: 0;
}
}
@media (min-width: 768px) and (max-width: 980px) {
.row {
margin-left: -20px;
*zoom: 1;
}
.row:before, .row:after {
display: table;
content: "";
}
.row:after {
clear: both;
}
[class*="span"] {
float: left;
margin-left: 20px;
}
.span1 {
width: 42px;
}
.span2 {
width: 104px;
}
.span3 {
width: 166px;
}
.span4 {
width: 228px;
}
.span5 {
width: 290px;
}
.span6 {
width: 352px;
}
.span7 {
width: 414px;
}
.span8 {
width: 476px;
}
.span9 {
width: 538px;
}
.span10 {
width: 600px;
}
.span11 {
width: 662px;
}
.span12, .container {
width: 724px;
}
.offset1 {
margin-left: 82px;
}
.offset2 {
margin-left: 144px;
}
.offset3 {
margin-left: 206px;
}
.offset4 {
margin-left: 268px;
}
.offset5 {
margin-left: 330px;
}
.offset6 {
margin-left: 392px;
}
.offset7 {
margin-left: 454px;
}
.offset8 {
margin-left: 516px;
}
.offset9 {
margin-left: 578px;
}
.offset10 {
margin-left: 640px;
}
.offset11 {
margin-left: 702px;
}
.row-fluid {
width: 100%;
*zoom: 1;
}
.row-fluid:before, .row-fluid:after {
display: table;
content: "";
}
.row-fluid:after {
clear: both;
}
.row-fluid > [class*="span"] {
float: left;
margin-left: 2.762430939%;
}
.row-fluid > [class*="span"]:first-child {
margin-left: 0;
}
.row-fluid .span1 {
width: 5.801104972%;
}
.row-fluid .span2 {
width: 14.364640883%;
}
.row-fluid .span3 {
width: 22.928176794%;
}
.row-fluid .span4 {
width: 31.491712705%;
}
.row-fluid .span5 {
width: 40.055248616%;
}
.row-fluid .span6 {
width: 48.618784527%;
}
.row-fluid .span7 {
width: 57.182320438000005%;
}
.row-fluid .span8 {
width: 65.74585634900001%;
}
.row-fluid .span9 {
width: 74.30939226%;
}
.row-fluid .span10 {
width: 82.87292817100001%;
}
.row-fluid .span11 {
width: 91.436464082%;
}
.row-fluid .span12 {
width: 99.999999993%;
}
input.span1, textarea.span1, .uneditable-input.span1 {
width: 32px;
}
input.span2, textarea.span2, .uneditable-input.span2 {
width: 94px;
}
input.span3, textarea.span3, .uneditable-input.span3 {
width: 156px;
}
input.span4, textarea.span4, .uneditable-input.span4 {
width: 218px;
}
input.span5, textarea.span5, .uneditable-input.span5 {
width: 280px;
}
input.span6, textarea.span6, .uneditable-input.span6 {
width: 342px;
}
input.span7, textarea.span7, .uneditable-input.span7 {
width: 404px;
}
input.span8, textarea.span8, .uneditable-input.span8 {
width: 466px;
}
input.span9, textarea.span9, .uneditable-input.span9 {
width: 528px;
}
input.span10, textarea.span10, .uneditable-input.span10 {
width: 590px;
}
input.span11, textarea.span11, .uneditable-input.span11 {
width: 652px;
}
input.span12, textarea.span12, .uneditable-input.span12 {
width: 714px;
}
}
@media (max-width: 980px) {
body {
padding-top: 0;
}
.navbar-fixed-top {
position: static;
margin-bottom: 18px;
}
.navbar-fixed-top .navbar-inner {
padding: 5px;
}
.navbar .container {
width: auto;
padding: 0;
}
.navbar .brand {
padding-left: 10px;
padding-right: 10px;
margin: 0 0 0 -5px;
}
.navbar .nav-collapse {
clear: left;
}
.navbar .nav {
float: none;
margin: 0 0 9px;
}
.navbar .nav > li {
float: none;
}
.navbar .nav > li > a {
margin-bottom: 2px;
}
.navbar .nav > .divider-vertical {
display: none;
}
.navbar .nav > li > a, .navbar .dropdown-menu a {
padding: 6px 15px;
font-weight: bold;
color: #999999;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
}
.navbar .dropdown-menu li + li a {
margin-bottom: 2px;
}
.navbar .nav > li > a:hover, .navbar .dropdown-menu a:hover {
background-color: #222222;
}
.navbar .dropdown-menu {
position: static;
top: auto;
left: auto;
float: none;
display: block;
max-width: none;
margin: 0 15px;
padding: 0;
background-color: transparent;
border: none;
-webkit-border-radius: 0;
-moz-border-radius: 0;
border-radius: 0;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
}
.navbar .dropdown-menu:before, .navbar .dropdown-menu:after {
display: none;
}
.navbar .dropdown-menu .divider {
display: none;
}
.navbar-form, .navbar-search {
float: none;
padding: 9px 15px;
margin: 9px 0;
border-top: 1px solid #222222;
border-bottom: 1px solid #222222;
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
-moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
}
.navbar .nav.pull-right {
float: none;
margin-left: 0;
}
.navbar-static .navbar-inner {
padding-left: 10px;
padding-right: 10px;
}
.btn-navbar {
display: block;
}
.nav-collapse {
overflow: hidden;
height: 0;
}
}
@media (min-width: 980px) {
.nav-collapse.collapse {
height: auto !important;
}
}
@media (min-width: 1200px) {
.row {
margin-left: -30px;
*zoom: 1;
}
.row:before, .row:after {
display: table;
content: "";
}
.row:after {
clear: both;
}
[class*="span"] {
float: left;
margin-left: 30px;
}
.span1 {
width: 70px;
}
.span2 {
width: 170px;
}
.span3 {
width: 270px;
}
.span4 {
width: 370px;
}
.span5 {
width: 470px;
}
.span6 {
width: 570px;
}
.span7 {
width: 670px;
}
.span8 {
width: 770px;
}
.span9 {
width: 870px;
}
.span10 {
width: 970px;
}
.span11 {
width: 1070px;
}
.span12, .container {
width: 1170px;
}
.offset1 {
margin-left: 130px;
}
.offset2 {
margin-left: 230px;
}
.offset3 {
margin-left: 330px;
}
.offset4 {
margin-left: 430px;
}
.offset5 {
margin-left: 530px;
}
.offset6 {
margin-left: 630px;
}
.offset7 {
margin-left: 730px;
}
.offset8 {
margin-left: 830px;
}
.offset9 {
margin-left: 930px;
}
.offset10 {
margin-left: 1030px;
}
.offset11 {
margin-left: 1130px;
}
.row-fluid {
width: 100%;
*zoom: 1;
}
.row-fluid:before, .row-fluid:after {
display: table;
content: "";
}
.row-fluid:after {
clear: both;
}
.row-fluid > [class*="span"] {
float: left;
margin-left: 2.564102564%;
}
.row-fluid > [class*="span"]:first-child {
margin-left: 0;
}
.row-fluid .span1 {
width: 5.982905983%;
}
.row-fluid .span2 {
width: 14.529914530000001%;
}
.row-fluid .span3 {
width: 23.076923077%;
}
.row-fluid .span4 {
width: 31.623931624%;
}
.row-fluid .span5 {
width: 40.170940171000005%;
}
.row-fluid .span6 {
width: 48.717948718%;
}
.row-fluid .span7 {
width: 57.264957265%;
}
.row-fluid .span8 {
width: 65.81196581200001%;
}
.row-fluid .span9 {
width: 74.358974359%;
}
.row-fluid .span10 {
width: 82.905982906%;
}
.row-fluid .span11 {
width: 91.45299145300001%;
}
.row-fluid .span12 {
width: 100%;
}
input.span1, textarea.span1, .uneditable-input.span1 {
width: 60px;
}
input.span2, textarea.span2, .uneditable-input.span2 {
width: 160px;
}
input.span3, textarea.span3, .uneditable-input.span3 {
width: 260px;
}
input.span4, textarea.span4, .uneditable-input.span4 {
width: 360px;
}
input.span5, textarea.span5, .uneditable-input.span5 {
width: 460px;
}
input.span6, textarea.span6, .uneditable-input.span6 {
width: 560px;
}
input.span7, textarea.span7, .uneditable-input.span7 {
width: 660px;
}
input.span8, textarea.span8, .uneditable-input.span8 {
width: 760px;
}
input.span9, textarea.span9, .uneditable-input.span9 {
width: 860px;
}
input.span10, textarea.span10, .uneditable-input.span10 {
width: 960px;
}
input.span11, textarea.span11, .uneditable-input.span11 {
width: 1060px;
}
input.span12, textarea.span12, .uneditable-input.span12 {
width: 1160px;
}
.thumbnails {
margin-left: -30px;
}
.thumbnails > li {
margin-left: 30px;
}
}
-3
View File
@@ -1,3 +0,0 @@
.hidden{display:none;visibility:hidden;}
@media (max-width:480px){.nav-collapse{-webkit-transform:translate3d(0, 0, 0);} .page-header h1 small{display:block;line-height:18px;} input[class*="span"],select[class*="span"],textarea[class*="span"],.uneditable-input{display:block;width:100%;height:28px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-ms-box-sizing:border-box;box-sizing:border-box;} .input-prepend input[class*="span"],.input-append input[class*="span"]{width:auto;} input[type="checkbox"],input[type="radio"]{border:1px solid #ccc;} .form-horizontal .control-group>label{float:none;width:auto;padding-top:0;text-align:left;} .form-horizontal .controls{margin-left:0;} .form-horizontal .control-list{padding-top:0;} .form-horizontal .form-actions{padding-left:10px;padding-right:10px;} .modal{position:absolute;top:10px;left:10px;right:10px;width:auto;margin:0;}.modal.fade.in{top:auto;} .modal-header .close{padding:10px;margin:-10px;} .carousel-caption{position:static;}}@media (max-width:768px){.container{width:auto;padding:0 20px;} .row-fluid{width:100%;} .row{margin-left:0;} .row>[class*="span"],.row-fluid>[class*="span"]{float:none;display:block;width:auto;margin:0;}}@media (min-width:768px) and (max-width:980px){.row{margin-left:-20px;*zoom:1;}.row:before,.row:after{display:table;content:"";} .row:after{clear:both;} [class*="span"]{float:left;margin-left:20px;} .span1{width:42px;} .span2{width:104px;} .span3{width:166px;} .span4{width:228px;} .span5{width:290px;} .span6{width:352px;} .span7{width:414px;} .span8{width:476px;} .span9{width:538px;} .span10{width:600px;} .span11{width:662px;} .span12,.container{width:724px;} .offset1{margin-left:82px;} .offset2{margin-left:144px;} .offset3{margin-left:206px;} .offset4{margin-left:268px;} .offset5{margin-left:330px;} .offset6{margin-left:392px;} .offset7{margin-left:454px;} .offset8{margin-left:516px;} .offset9{margin-left:578px;} .offset10{margin-left:640px;} .offset11{margin-left:702px;} .row-fluid{width:100%;*zoom:1;}.row-fluid:before,.row-fluid:after{display:table;content:"";} .row-fluid:after{clear:both;} .row-fluid>[class*="span"]{float:left;margin-left:2.762430939%;} .row-fluid>[class*="span"]:first-child{margin-left:0;} .row-fluid .span1{width:5.801104972%;} .row-fluid .span2{width:14.364640883%;} .row-fluid .span3{width:22.928176794%;} .row-fluid .span4{width:31.491712705%;} .row-fluid .span5{width:40.055248616%;} .row-fluid .span6{width:48.618784527%;} .row-fluid .span7{width:57.182320438000005%;} .row-fluid .span8{width:65.74585634900001%;} .row-fluid .span9{width:74.30939226%;} .row-fluid .span10{width:82.87292817100001%;} .row-fluid .span11{width:91.436464082%;} .row-fluid .span12{width:99.999999993%;} input.span1,textarea.span1,.uneditable-input.span1{width:32px;} input.span2,textarea.span2,.uneditable-input.span2{width:94px;} input.span3,textarea.span3,.uneditable-input.span3{width:156px;} input.span4,textarea.span4,.uneditable-input.span4{width:218px;} input.span5,textarea.span5,.uneditable-input.span5{width:280px;} input.span6,textarea.span6,.uneditable-input.span6{width:342px;} input.span7,textarea.span7,.uneditable-input.span7{width:404px;} input.span8,textarea.span8,.uneditable-input.span8{width:466px;} input.span9,textarea.span9,.uneditable-input.span9{width:528px;} input.span10,textarea.span10,.uneditable-input.span10{width:590px;} input.span11,textarea.span11,.uneditable-input.span11{width:652px;} input.span12,textarea.span12,.uneditable-input.span12{width:714px;}}@media (max-width:980px){body{padding-top:0;} .navbar-fixed-top{position:static;margin-bottom:18px;} .navbar-fixed-top .navbar-inner{padding:5px;} .navbar .container{width:auto;padding:0;} .navbar .brand{padding-left:10px;padding-right:10px;margin:0 0 0 -5px;} .navbar .nav-collapse{clear:left;} .navbar .nav{float:none;margin:0 0 9px;} .navbar .nav>li{float:none;} .navbar .nav>li>a{margin-bottom:2px;} .navbar .nav>.divider-vertical{display:none;} .navbar .nav>li>a,.navbar .dropdown-menu a{padding:6px 15px;font-weight:bold;color:#999999;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;} .navbar .dropdown-menu li+li a{margin-bottom:2px;} .navbar .nav>li>a:hover,.navbar .dropdown-menu a:hover{background-color:#222222;} .navbar .dropdown-menu{position:static;top:auto;left:auto;float:none;display:block;max-width:none;margin:0 15px;padding:0;background-color:transparent;border:none;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;} .navbar .dropdown-menu:before,.navbar .dropdown-menu:after{display:none;} .navbar .dropdown-menu .divider{display:none;} .navbar-form,.navbar-search{float:none;padding:9px 15px;margin:9px 0;border-top:1px solid #222222;border-bottom:1px solid #222222;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.1);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.1);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 25Line truncated
-3365
View File
File diff suppressed because it is too large. Load diff
-610
View File
@@ -1,610 +0,0 @@
article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block;}
audio,canvas,video{display:inline-block;*display:inline;*zoom:1;}
audio:not([controls]){display:none;}
html{font-size:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px;}
a:hover,a:active{outline:0;}
sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline;}
sup{top:-0.5em;}
sub{bottom:-0.25em;}
img{max-width:100%;height:auto;border:0;-ms-interpolation-mode:bicubic;}
button,input,select,textarea{margin:0;font-size:100%;vertical-align:middle;}
button,input{*overflow:visible;line-height:normal;}
button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0;}
button,input[type="button"],input[type="reset"],input[type="submit"]{cursor:pointer;-webkit-appearance:button;}
input[type="search"]{-webkit-appearance:textfield;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;}
input[type="search"]::-webkit-search-decoration,input[type="search"]::-webkit-search-cancel-button{-webkit-appearance:none;}
textarea{overflow:auto;vertical-align:top;}
body{margin:0;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;line-height:18px;color:#333333;background-color:#ffffff;}
a{color:#0088cc;text-decoration:none;}
a:hover{color:#005580;text-decoration:underline;}
.row{margin-left:-20px;*zoom:1;}.row:before,.row:after{display:table;content:"";}
.row:after{clear:both;}
[class*="span"]{float:left;margin-left:20px;}
.span1{width:60px;}
.span2{width:140px;}
.span3{width:220px;}
.span4{width:300px;}
.span5{width:380px;}
.span6{width:460px;}
.span7{width:540px;}
.span8{width:620px;}
.span9{width:700px;}
.span10{width:780px;}
.span11{width:860px;}
.span12,.container{width:940px;}
.offset1{margin-left:100px;}
.offset2{margin-left:180px;}
.offset3{margin-left:260px;}
.offset4{margin-left:340px;}
.offset5{margin-left:420px;}
.offset6{margin-left:500px;}
.offset7{margin-left:580px;}
.offset8{margin-left:660px;}
.offset9{margin-left:740px;}
.offset10{margin-left:820px;}
.offset11{margin-left:900px;}
.row-fluid{width:100%;*zoom:1;}.row-fluid:before,.row-fluid:after{display:table;content:"";}
.row-fluid:after{clear:both;}
.row-fluid>[class*="span"]{float:left;margin-left:2.127659574%;}
.row-fluid>[class*="span"]:first-child{margin-left:0;}
.row-fluid .span1{width:6.382978723%;}
.row-fluid .span2{width:14.89361702%;}
.row-fluid .span3{width:23.404255317%;}
.row-fluid .span4{width:31.914893614%;}
.row-fluid .span5{width:40.425531911%;}
.row-fluid .span6{width:48.93617020799999%;}
.row-fluid .span7{width:57.446808505%;}
.row-fluid .span8{width:65.95744680199999%;}
.row-fluid .span9{width:74.468085099%;}
.row-fluid .span10{width:82.97872339599999%;}
.row-fluid .span11{width:91.489361693%;}
.row-fluid .span12{width:99.99999998999999%;}
.container{width:940px;margin-left:auto;margin-right:auto;*zoom:1;}.container:before,.container:after{display:table;content:"";}
.container:after{clear:both;}
.container-fluid{padding-left:20px;padding-right:20px;*zoom:1;}.container-fluid:before,.container-fluid:after{display:table;content:"";}
.container-fluid:after{clear:both;}
p{margin:0 0 9px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;line-height:18px;}p small{font-size:11px;color:#999999;}
.lead{margin-bottom:18px;font-size:20px;font-weight:200;line-height:27px;}
h1,h2,h3,h4,h5,h6{margin:0;font-weight:bold;color:#333333;text-rendering:optimizelegibility;}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small{font-weight:normal;color:#999999;}
h1{font-size:30px;line-height:36px;}h1 small{font-size:18px;}
h2{font-size:24px;line-height:36px;}h2 small{font-size:18px;}
h3{line-height:27px;font-size:18px;}h3 small{font-size:14px;}
h4,h5,h6{line-height:18px;}
h4{font-size:14px;}h4 small{font-size:12px;}
h5{font-size:12px;}
h6{font-size:11px;color:#999999;text-transform:uppercase;}
.page-header{padding-bottom:17px;margin:18px 0;border-bottom:1px solid #eeeeee;}
.page-header h1{line-height:1;}
ul,ol{padding:0;margin:0 0 9px 25px;}
ul ul,ul ol,ol ol,ol ul{margin-bottom:0;}
ul{list-style:disc;}
ol{list-style:decimal;}
li{line-height:18px;}
ul.unstyled{margin-left:0;list-style:none;}
dl{margin-bottom:18px;}
dt,dd{line-height:18px;}
dt{font-weight:bold;}
dd{margin-left:9px;}
hr{margin:18px 0;border:0;border-top:1px solid #e5e5e5;border-bottom:1px solid #ffffff;}
strong{font-weight:bold;}
em{font-style:italic;}
.muted{color:#999999;}
abbr{font-size:90%;text-transform:uppercase;border-bottom:1px dotted #ddd;cursor:help;}
blockquote{padding:0 0 0 15px;margin:0 0 18px;border-left:5px solid #eeeeee;}blockquote p{margin-bottom:0;font-size:16px;font-weight:300;line-height:22.5px;}
blockquote small{display:block;line-height:18px;color:#999999;}blockquote small:before{content:'\2014 \00A0';}
blockquote.pull-right{float:right;padding-left:0;padding-right:15px;border-left:0;border-right:5px solid #eeeeee;}blockquote.pull-right p,blockquote.pull-right small{text-align:right;}
q:before,q:after,blockquote:before,blockquote:after{content:"";}
address{display:block;margin-bottom:18px;line-height:18px;font-style:normal;}
small{font-size:100%;}
cite{font-style:normal;}
code,pre{padding:0 3px 2px;font-family:Menlo,Monaco,"Courier New",monospace;font-size:12px;color:#333333;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}
code{padding:3px 4px;color:#d14;background-color:#f7f7f9;border:1px solid #e1e1e8;}
pre{display:block;padding:8.5px;margin:0 0 9px;font-size:12px;line-height:18px;background-color:#f5f5f5;border:1px solid #ccc;border:1px solid rgba(0, 0, 0, 0.15);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;white-space:pre;white-space:pre-wrap;word-break:break-all;}pre.prettyprint{margin-bottom:18px;}
pre code{padding:0;background-color:transparent;}
form{margin:0 0 18px;}
fieldset{padding:0;margin:0;border:0;}
legend{display:block;width:100%;padding:0;margin-bottom:27px;font-size:19.5px;line-height:36px;color:#333333;border:0;border-bottom:1px solid #eee;}
label,input,button,select,textarea{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;font-weight:normal;line-height:18px;}
label{display:block;margin-bottom:5px;color:#333333;}
input,textarea,select,.uneditable-input{display:inline-block;width:210px;height:18px;padding:4px;margin-bottom:9px;font-size:13px;line-height:18px;color:#555555;border:1px solid #ccc;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}
.uneditable-textarea{width:auto;height:auto;}
label input,label textarea,label select{display:block;}
input[type="image"],input[type="checkbox"],input[type="radio"]{width:auto;height:auto;padding:0;margin:3px 0;*margin-top:0;line-height:normal;border:0;cursor:pointer;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
input[type="file"]{padding:initial;line-height:initial;border:initial;background-color:#ffffff;background-color:initial;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
input[type="button"],input[type="reset"],input[type="submit"]{width:auto;height:auto;}
select,input[type="file"]{height:28px;*margin-top:4px;line-height:28px;}
select{width:220px;background-color:#ffffff;}
select[multiple],select[size]{height:auto;}
input[type="image"]{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
textarea{height:auto;}
input[type="hidden"]{display:none;}
.radio,.checkbox{padding-left:18px;}
.radio input[type="radio"],.checkbox input[type="checkbox"]{float:left;margin-left:-18px;}
.controls>.radio:first-child,.controls>.checkbox:first-child{padding-top:5px;}
.radio.inline,.checkbox.inline{display:inline-block;margin-bottom:0;vertical-align:middle;}
.radio.inline+.radio.inline,.checkbox.inline+.checkbox.inline{margin-left:10px;}
.controls>.radio.inline:first-child,.controls>.checkbox.inline:first-child{padding-top:0;}
input,textarea{-webkit-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075);-moz-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075);box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075);-webkit-transition:border linear 0.2s,box-shadow linear 0.2s;-moz-transition:border linear 0.2s,box-shadow linear 0.2s;-ms-transition:border linear 0.2s,box-shadow linear 0.2s;-o-transition:border linear 0.2s,box-shadow linear 0.2s;transition:border linear 0.2s,box-shadow linear 0.2s;}
input:focus,textarea:focus{border-color:rgba(82, 168, 236, 0.8);-webkit-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075),0 0 8px rgba(82, 168, 236, 0.6);-moz-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075),0 0 8px rgba(82, 168, 236, 0.6);box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075),0 0 8px rgba(82, 168, 236, 0.6);outline:0;outline:thin dotted \9;}
input[type="file"]:focus,input[type="checkbox"]:focus,select:focus{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px;}
.input-mini{width:60px;}
.input-small{width:90px;}
.input-medium{width:150px;}
.input-large{width:210px;}
.input-xlarge{width:270px;}
.input-xxlarge{width:530px;}
input[class*="span"],select[class*="span"],textarea[class*="span"],.uneditable-input{float:none;margin-left:0;}
input.span1,textarea.span1,.uneditable-input.span1{width:50px;}
input.span2,textarea.span2,.uneditable-input.span2{width:130px;}
input.span3,textarea.span3,.uneditable-input.span3{width:210px;}
input.span4,textarea.span4,.uneditable-input.span4{width:290px;}
input.span5,textarea.span5,.uneditable-input.span5{width:370px;}
input.span6,textarea.span6,.uneditable-input.span6{width:450px;}
input.span7,textarea.span7,.uneditable-input.span7{width:530px;}
input.span8,textarea.span8,.uneditable-input.span8{width:610px;}
input.span9,textarea.span9,.uneditable-input.span9{width:690px;}
input.span10,textarea.span10,.uneditable-input.span10{width:770px;}
input.span11,textarea.span11,.uneditable-input.span11{width:850px;}
input.span12,textarea.span12,.uneditable-input.span12{width:930px;}
input[disabled],select[disabled],textarea[disabled],input[readonly],select[readonly],textarea[readonly]{background-color:#f5f5f5;border-color:#ddd;cursor:not-allowed;}
.control-group.warning>label,.control-group.warning .help-block,.control-group.warning .help-inline{color:#c09853;}
.control-group.warning input,.control-group.warning select,.control-group.warning textarea{color:#c09853;border-color:#c09853;}.control-group.warning input:focus,.control-group.warning select:focus,.control-group.warning textarea:focus{border-color:#a47e3c;-webkit-box-shadow:0 0 6px #dbc59e;-moz-box-shadow:0 0 6px #dbc59e;box-shadow:0 0 6px #dbc59e;}
.control-group.warning .input-prepend .add-on,.control-group.warning .input-append .add-on{color:#c09853;background-color:#fcf8e3;border-color:#c09853;}
.control-group.error>label,.control-group.error .help-block,.control-group.error .help-inline{color:#b94a48;}
.control-group.error input,.control-group.error select,.control-group.error textarea{color:#b94a48;border-color:#b94a48;}.control-group.error input:focus,.control-group.error select:focus,.control-group.error textarea:focus{border-color:#953b39;-webkit-box-shadow:0 0 6px #d59392;-moz-box-shadow:0 0 6px #d59392;box-shadow:0 0 6px #d59392;}
.control-group.error .input-prepend .add-on,.control-group.error .input-append .add-on{color:#b94a48;background-color:#f2dede;border-color:#b94a48;}
.control-group.success>label,.control-group.success .help-block,.control-group.success .help-inline{color:#468847;}
.control-group.success input,.control-group.success select,.control-group.success textarea{color:#468847;border-color:#468847;}.control-group.success input:focus,.control-group.success select:focus,.control-group.success textarea:focus{border-color:#356635;-webkit-box-shadow:0 0 6px #7aba7b;-moz-box-shadow:0 0 6px #7aba7b;box-shadow:0 0 6px #7aba7b;}
.control-group.success .input-prepend .add-on,.control-group.success .input-append .add-on{color:#468847;background-color:#dff0d8;border-color:#468847;}
input:focus:required:invalid,textarea:focus:required:invalid,select:focus:required:invalid{color:#b94a48;border-color:#ee5f5b;}input:focus:required:invalid:focus,textarea:focus:required:invalid:focus,select:focus:required:invalid:focus{border-color:#e9322d;-webkit-box-shadow:0 0 6px #f8b9b7;-moz-box-shadow:0 0 6px #f8b9b7;box-shadow:0 0 6px #f8b9b7;}
.form-actions{padding:17px 20px 18px;margin-top:18px;margin-bottom:18px;background-color:#f5f5f5;border-top:1px solid #ddd;}
.uneditable-input{display:block;background-color:#ffffff;border-color:#eee;-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);cursor:not-allowed;}
:-moz-placeholder{color:#999999;}
::-webkit-input-placeholder{color:#999999;}
.help-block{margin-top:5px;margin-bottom:0;color:#999999;}
.help-inline{display:inline-block;*display:inline;*zoom:1;margin-bottom:9px;vertical-align:middle;padding-left:5px;}
.input-prepend,.input-append{margin-bottom:5px;*zoom:1;}.input-prepend:before,.input-append:before,.input-prepend:after,.input-append:after{display:table;content:"";}
.input-prepend:after,.input-append:after{clear:both;}
.input-prepend input,.input-append input,.input-prepend .uneditable-input,.input-append .uneditable-input{-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;}.input-prepend input:focus,.input-append input:focus,.input-prepend .uneditable-input:focus,.input-append .uneditable-input:focus{position:relative;z-index:2;}
.input-prepend .uneditable-input,.input-append .uneditable-input{border-left-color:#ccc;}
.input-prepend .add-on,.input-append .add-on{float:left;display:block;width:auto;min-width:16px;height:18px;margin-right:-1px;padding:4px 5px;font-weight:normal;line-height:18px;color:#999999;text-align:center;text-shadow:0 1px 0 #ffffff;background-color:#f5f5f5;border:1px solid #ccc;-webkit-border-radius:3px 0 0 3px;-moz-border-radius:3px 0 0 3px;border-radius:3px 0 0 3px;}
.input-prepend .active,.input-append .active{background-color:#a9dba9;border-color:#46a546;}
.input-prepend .add-on{*margin-top:1px;}
.input-append input,.input-append .uneditable-input{float:left;-webkit-border-radius:3px 0 0 3px;-moz-border-radius:3px 0 0 3px;border-radius:3px 0 0 3px;}
.input-append .uneditable-input{border-right-color:#ccc;}
.input-append .add-on{margin-right:0;margin-left:-1px;-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;}
.input-append input:first-child{*margin-left:-160px;}.input-append input:first-child+.add-on{*margin-left:-21px;}
.search-query{padding-left:14px;padding-right:14px;margin-bottom:0;-webkit-border-radius:14px;-moz-border-radius:14px;border-radius:14px;}
.form-search input,.form-inline input,.form-horizontal input,.form-search textarea,.form-inline textarea,.form-horizontal textarea,.form-search select,.form-inline select,.form-horizontal select,.form-search .help-inline,.form-inline .help-inline,.form-horizontal .help-inline,.form-search .uneditable-input,.form-inline .uneditable-input,.form-horizontal .uneditable-input{display:inline-block;margin-bottom:0;}
.form-search label,.form-inline label,.form-search .input-append,.form-inline .input-append,.form-search .input-prepend,.form-inline .input-prepend{display:inline-block;}
.form-search .input-append .add-on,.form-inline .input-prepend .add-on,.form-search .input-append .add-on,.form-inline .input-prepend .add-on{vertical-align:middle;}
.control-group{margin-bottom:9px;}
.form-horizontal legend+.control-group{margin-top:18px;-webkit-margin-top-collapse:separate;}
.form-horizontal .control-group{margin-bottom:18px;*zoom:1;}.form-horizontal .control-group:before,.form-horizontal .control-group:after{display:table;content:"";}
.form-horizontal .control-group:after{clear:both;}
.form-horizontal .control-group>label{float:left;width:140px;padding-top:5px;text-align:right;}
.form-horizontal .controls{margin-left:160px;}
.form-horizontal .form-actions{padding-left:160px;}
table{max-width:100%;border-collapse:collapse;border-spacing:0;}
.table{width:100%;margin-bottom:18px;}.table th,.table td{padding:8px;line-height:18px;text-align:left;border-top:1px solid #ddd;}
.table th{font-weight:bold;vertical-align:bottom;}
.table td{vertical-align:top;}
.table thead:first-child tr th,.table thead:first-child tr td{border-top:0;}
.table tbody+tbody{border-top:2px solid #ddd;}
.table-condensed th,.table-condensed td{padding:4px 5px;}
.table-bordered{border:1px solid #ddd;border-collapse:separate;*border-collapse:collapsed;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}.table-bordered th+th,.table-bordered td+td,.table-bordered th+td,.table-bordered td+th{border-left:1px solid #ddd;}
.table-bordered thead:first-child tr:first-child th,.table-bordered tbody:first-child tr:first-child th,.table-bordered tbody:first-child tr:first-child td{border-top:0;}
.table-bordered thead:first-child tr:first-child th:first-child,.table-bordered tbody:first-child tr:first-child td:first-child{-webkit-border-radius:4px 0 0 0;-moz-border-radius:4px 0 0 0;border-radius:4px 0 0 0;}
.table-bordered thead:first-child tr:first-child th:last-child,.table-bordered tbody:first-child tr:first-child td:last-child{-webkit-border-radius:0 4px 0 0;-moz-border-radius:0 4px 0 0;border-radius:0 4px 0 0;}
.table-bordered thead:last-child tr:last-child th:first-child,.table-bordered tbody:last-child tr:last-child td:first-child{-webkit-border-radius:0 0 0 4px;-moz-border-radius:0 0 0 4px;border-radius:0 0 0 4px;}
.table-bordered thead:last-child tr:last-child th:last-child,.table-bordered tbody:last-child tr:last-child td:last-child{-webkit-border-radius:0 0 4px 0;-moz-border-radius:0 0 4px 0;border-radius:0 0 4px 0;}
.table-striped tbody tr:nth-child(odd) td,.table-striped tbody tr:nth-child(odd) th{background-color:#f9f9f9;}
table .span1{float:none;width:44px;margin-left:0;}
table .span2{float:none;width:124px;margin-left:0;}
table .span3{float:none;width:204px;margin-left:0;}
table .span4{float:none;width:284px;margin-left:0;}
table .span5{float:none;width:364px;margin-left:0;}
table .span6{float:none;width:444px;margin-left:0;}
table .span7{float:none;width:524px;margin-left:0;}
table .span8{float:none;width:604px;margin-left:0;}
table .span9{float:none;width:684px;margin-left:0;}
table .span10{float:none;width:764px;margin-left:0;}
table .span11{float:none;width:844px;margin-left:0;}
table .span12{float:none;width:924px;margin-left:0;}
[class^="icon-"]{display:inline-block;width:14px;height:14px;vertical-align:text-top;background-image:url(../img/glyphicons-halflings.png);background-position:14px 14px;background-repeat:no-repeat;*margin-right:.3em;}[class^="icon-"]:last-child{*margin-left:0;}
.icon-white{background-image:url(../img/glyphicons-halflings-white.png);}
.icon-glass{background-position:0 0;}
.icon-music{background-position:-24px 0;}
.icon-search{background-position:-48px 0;}
.icon-envelope{background-position:-72px 0;}
.icon-heart{background-position:-96px 0;}
.icon-star{background-position:-120px 0;}
.icon-star-empty{background-position:-144px 0;}
.icon-user{background-position:-168px 0;}
.icon-film{background-position:-192px 0;}
.icon-th-large{background-position:-216px 0;}
.icon-th{background-position:-240px 0;}
.icon-th-list{background-position:-264px 0;}
.icon-ok{background-position:-288px 0;}
.icon-remove{background-position:-312px 0;}
.icon-zoom-in{background-position:-336px 0;}
.icon-zoom-out{background-position:-360px 0;}
.icon-off{background-position:-384px 0;}
.icon-signal{background-position:-408px 0;}
.icon-cog{background-position:-432px 0;}
.icon-trash{background-position:-456px 0;}
.icon-home{background-position:0 -24px;}
.icon-file{background-position:-24px -24px;}
.icon-time{background-position:-48px -24px;}
.icon-road{background-position:-72px -24px;}
.icon-download-alt{background-position:-96px -24px;}
.icon-download{background-position:-120px -24px;}
.icon-upload{background-position:-144px -24px;}
.icon-inbox{background-position:-168px -24px;}
.icon-play-circle{background-position:-192px -24px;}
.icon-repeat{background-position:-216px -24px;}
.icon-refresh{background-position:-240px -24px;}
.icon-list-alt{background-position:-264px -24px;}
.icon-lock{background-position:-287px -24px;}
.icon-flag{background-position:-312px -24px;}
.icon-headphones{background-position:-336px -24px;}
.icon-volume-off{background-position:-360px -24px;}
.icon-volume-down{background-position:-384px -24px;}
.icon-volume-up{background-position:-408px -24px;}
.icon-qrcode{background-position:-432px -24px;}
.icon-barcode{background-position:-456px -24px;}
.icon-tag{background-position:0 -48px;}
.icon-tags{background-position:-25px -48px;}
.icon-book{background-position:-48px -48px;}
.icon-bookmark{background-position:-72px -48px;}
.icon-print{background-position:-96px -48px;}
.icon-camera{background-position:-120px -48px;}
.icon-font{background-position:-144px -48px;}
.icon-bold{background-position:-167px -48px;}
.icon-italic{background-position:-192px -48px;}
.icon-text-height{background-position:-216px -48px;}
.icon-text-width{background-position:-240px -48px;}
.icon-align-left{background-position:-264px -48px;}
.icon-align-center{background-position:-288px -48px;}
.icon-align-right{background-position:-312px -48px;}
.icon-align-justify{background-position:-336px -48px;}
.icon-list{background-position:-360px -48px;}
.icon-indent-left{background-position:-384px -48px;}
.icon-indent-right{background-position:-408px -48px;}
.icon-facetime-video{background-position:-432px -48px;}
.icon-picture{background-position:-456px -48px;}
.icon-pencil{background-position:0 -72px;}
.icon-map-marker{background-position:-24px -72px;}
.icon-adjust{background-position:-48px -72px;}
.icon-tint{background-position:-72px -72px;}
.icon-edit{background-position:-96px -72px;}
.icon-share{background-position:-120px -72px;}
.icon-check{background-position:-144px -72px;}
.icon-move{background-position:-168px -72px;}
.icon-step-backward{background-position:-192px -72px;}
.icon-fast-backward{background-position:-216px -72px;}
.icon-backward{background-position:-240px -72px;}
.icon-play{background-position:-264px -72px;}
.icon-pause{background-position:-288px -72px;}
.icon-stop{background-position:-312px -72px;}
.icon-forward{background-position:-336px -72px;}
.icon-fast-forward{background-position:-360px -72px;}
.icon-step-forward{background-position:-384px -72px;}
.icon-eject{background-position:-408px -72px;}
.icon-chevron-left{background-position:-432px -72px;}
.icon-chevron-right{background-position:-456px -72px;}
.icon-plus-sign{background-position:0 -96px;}
.icon-minus-sign{background-position:-24px -96px;}
.icon-remove-sign{background-position:-48px -96px;}
.icon-ok-sign{background-position:-72px -96px;}
.icon-question-sign{background-position:-96px -96px;}
.icon-info-sign{background-position:-120px -96px;}
.icon-screenshot{background-position:-144px -96px;}
.icon-remove-circle{background-position:-168px -96px;}
.icon-ok-circle{background-position:-192px -96px;}
.icon-ban-circle{background-position:-216px -96px;}
.icon-arrow-left{background-position:-240px -96px;}
.icon-arrow-right{background-position:-264px -96px;}
.icon-arrow-up{background-position:-289px -96px;}
.icon-arrow-down{background-position:-312px -96px;}
.icon-share-alt{background-position:-336px -96px;}
.icon-resize-full{background-position:-360px -96px;}
.icon-resize-small{background-position:-384px -96px;}
.icon-plus{background-position:-408px -96px;}
.icon-minus{background-position:-433px -96px;}
.icon-asterisk{background-position:-456px -96px;}
.icon-exclamation-sign{background-position:0 -120px;}
.icon-gift{background-position:-24px -120px;}
.icon-leaf{background-position:-48px -120px;}
.icon-fire{background-position:-72px -120px;}
.icon-eye-open{background-position:-96px -120px;}
.icon-eye-close{background-position:-120px -120px;}
.icon-warning-sign{background-position:-144px -120px;}
.icon-plane{background-position:-168px -120px;}
.icon-calendar{background-position:-192px -120px;}
.icon-random{background-position:-216px -120px;}
.icon-comment{background-position:-240px -120px;}
.icon-magnet{background-position:-264px -120px;}
.icon-chevron-up{background-position:-288px -120px;}
.icon-chevron-down{background-position:-313px -119px;}
.icon-retweet{background-position:-336px -120px;}
.icon-shopping-cart{background-position:-360px -120px;}
.icon-folder-close{background-position:-384px -120px;}
.icon-folder-open{background-position:-408px -120px;}
.icon-resize-vertical{background-position:-432px -119px;}
.icon-resize-horizontal{background-position:-456px -118px;}
.dropdown{position:relative;}
.dropdown-toggle{*margin-bottom:-3px;}
.dropdown-toggle:active,.open .dropdown-toggle{outline:0;}
.caret{display:inline-block;width:0;height:0;text-indent:-99999px;*text-indent:0;vertical-align:top;border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid #000000;opacity:0.3;filter:alpha(opacity=30);content:"\2193";}
.dropdown .caret{margin-top:8px;margin-left:2px;}
.dropdown:hover .caret,.open.dropdown .caret{opacity:1;filter:alpha(opacity=100);}
.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;float:left;display:none;min-width:160px;max-width:220px;_width:160px;padding:4px 0;margin:0;list-style:none;background-color:#ffffff;border-color:#ccc;border-color:rgba(0, 0, 0, 0.2);border-style:solid;border-width:1px;-webkit-border-radius:0 0 5px 5px;-moz-border-radius:0 0 5px 5px;border-radius:0 0 5px 5px;-webkit-box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);-moz-box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);-webkit-background-clip:padding-box;-moz-background-clip:padding;background-clip:padding-box;*border-right-width:2px;*border-bottom-width:2px;}.dropdown-menu.bottom-up{top:auto;bottom:100%;margin-bottom:2px;}
.dropdown-menu .divider{height:1px;margin:5px 1px;overflow:hidden;background-color:#e5e5e5;border-bottom:1px solid #ffffff;*width:100%;*margin:-5px 0 5px;}
.dropdown-menu a{display:block;padding:3px 15px;clear:both;font-weight:normal;line-height:18px;color:#555555;white-space:nowrap;}
.dropdown-menu li>a:hover,.dropdown-menu .active>a,.dropdown-menu .active>a:hover{color:#ffffff;text-decoration:none;background-color:#0088cc;}
.dropdown.open{*z-index:1000;}.dropdown.open .dropdown-toggle{color:#ffffff;background:#ccc;background:rgba(0, 0, 0, 0.3);}
.dropdown.open .dropdown-menu{display:block;}
.typeahead{margin-top:2px;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #eee;border:1px solid rgba(0, 0, 0, 0.05);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);}.well blockquote{border-color:#ddd;border-color:rgba(0, 0, 0, 0.15);}
.fade{-webkit-transition:opacity 0.15s linear;-moz-transition:opacity 0.15s linear;-ms-transition:opacity 0.15s linear;-o-transition:opacity 0.15s linear;transition:opacity 0.15s linear;opacity:0;}.fade.in{opacity:1;}
.collapse{-webkit-transition:height 0.35s ease;-moz-transition:height 0.35s ease;-ms-transition:height 0.35s ease;-o-transition:height 0.35s ease;transition:height 0.35s ease;position:relative;overflow:hidden;height:0;}.collapse.in{height:auto;}
.close{float:right;font-size:20px;font-weight:bold;line-height:18px;color:#000000;text-shadow:0 1px 0 #ffffff;opacity:0.2;filter:alpha(opacity=20);}.close:hover{color:#000000;text-decoration:none;opacity:0.4;filter:alpha(opacity=40);cursor:pointer;}
.btn{display:inline-block;padding:4px 10px 4px;font-size:13px;line-height:18px;color:#333333;text-align:center;text-shadow:0 1px 1px rgba(255, 255, 255, 0.75);background-color:#fafafa;background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));background-image:-webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);background-image:-ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-repeat:no-repeat;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0);border:1px solid #ccc;border-bottom-color:#bbb;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);cursor:pointer;*margin-left:.3em;}.btn:first-child{*margin-left:0;}
.btn:hover{color:#333333;text-decoration:none;background-color:#e6e6e6;background-position:0 -15px;-webkit-transition:background-position 0.1s linear;-moz-transition:background-position 0.1s linear;-ms-transition:background-position 0.1s linear;-o-transition:background-position 0.1s linear;transition:background-position 0.1s linear;}
.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px;}
.btn.active,.btn:active{background-image:none;-webkit-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);background-color:#e6e6e6;background-color:#d9d9d9 \9;color:rgba(0, 0, 0, 0.5);outline:0;}
.btn.disabled,.btn[disabled]{cursor:default;background-image:none;background-color:#e6e6e6;opacity:0.65;filter:alpha(opacity=65);-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
.btn-large{padding:9px 14px;font-size:15px;line-height:normal;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;}
.btn-large .icon{margin-top:1px;}
.btn-small{padding:5px 9px;font-size:11px;line-height:16px;}
.btn-small .icon{margin-top:-1px;}
.btn-primary,.btn-primary:hover,.btn-warning,.btn-warning:hover,.btn-danger,.btn-danger:hover,.btn-success,.btn-success:hover,.btn-info,.btn-info:hover{text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);color:#ffffff;}
.btn-primary.active,.btn-warning.active,.btn-danger.active,.btn-success.active,.btn-info.active{color:rgba(255, 255, 255, 0.75);}
.btn-primary{background-color:#006dcc;background-image:-moz-linear-gradient(top, #0088cc, #0044cc);background-image:-ms-linear-gradient(top, #0088cc, #0044cc);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc));background-image:-webkit-linear-gradient(top, #0088cc, #0044cc);background-image:-o-linear-gradient(top, #0088cc, #0044cc);background-image:linear-gradient(top, #0088cc, #0044cc);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#0088cc', endColorstr='#0044cc', GradientType=0);border-color:#0044cc #0044cc #002a80;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-primary:hover,.btn-primary:active,.btn-primary.active,.btn-primary.disabled,.btn-primary[disabled]{background-color:#0044cc;}
.btn-primary:active,.btn-primary.active{background-color:#003399 \9;}
.btn-warning{background-color:#faa732;background-image:-moz-linear-gradient(top, #fbb450, #f89406);background-image:-ms-linear-gradient(top, #fbb450, #f89406);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#fbb450), to(#f89406));background-image:-webkit-linear-gradient(top, #fbb450, #f89406);background-image:-o-linear-gradient(top, #fbb450, #f89406);background-image:linear-gradient(top, #fbb450, #f89406);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fbb450', endColorstr='#f89406', GradientType=0);border-color:#f89406 #f89406 #ad6704;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-warning:hover,.btn-warning:active,.btn-warning.active,.btn-warning.disabled,.btn-warning[disabled]{background-color:#f89406;}
.btn-warning:active,.btn-warning.active{background-color:#c67605 \9;}
.btn-danger{background-color:#da4f49;background-image:-moz-linear-gradient(top, #ee5f5b, #bd362f);background-image:-ms-linear-gradient(top, #ee5f5b, #bd362f);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ee5f5b), to(#bd362f));background-image:-webkit-linear-gradient(top, #ee5f5b, #bd362f);background-image:-o-linear-gradient(top, #ee5f5b, #bd362f);background-image:linear-gradient(top, #ee5f5b, #bd362f);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#bd362f', GradientType=0);border-color:#bd362f #bd362f #802420;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-danger:hover,.btn-danger:active,.btn-danger.active,.btn-danger.disabled,.btn-danger[disabled]{background-color:#bd362f;}
.btn-danger:active,.btn-danger.active{background-color:#942a25 \9;}
.btn-success{background-color:#5bb75b;background-image:-moz-linear-gradient(top, #62c462, #51a351);background-image:-ms-linear-gradient(top, #62c462, #51a351);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#62c462), to(#51a351));background-image:-webkit-linear-gradient(top, #62c462, #51a351);background-image:-o-linear-gradient(top, #62c462, #51a351);background-image:linear-gradient(top, #62c462, #51a351);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#62c462', endColorstr='#51a351', GradientType=0);border-color:#51a351 #51a351 #387038;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-success:hover,.btn-success:active,.btn-success.active,.btn-success.disabled,.btn-success[disabled]{background-color:#51a351;}
.btn-success:active,.btn-success.active{background-color:#408140 \9;}
.btn-info{background-color:#49afcd;background-image:-moz-linear-gradient(top, #5bc0de, #2f96b4);background-image:-ms-linear-gradient(top, #5bc0de, #2f96b4);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#5bc0de), to(#2f96b4));background-image:-webkit-linear-gradient(top, #5bc0de, #2f96b4);background-image:-o-linear-gradient(top, #5bc0de, #2f96b4);background-image:linear-gradient(top, #5bc0de, #2f96b4);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#5bc0de', endColorstr='#2f96b4', GradientType=0);border-color:#2f96b4 #2f96b4 #1f6377;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-info:hover,.btn-info:active,.btn-info.active,.btn-info.disabled,.btn-info[disabled]{background-color:#2f96b4;}
.btn-info:active,.btn-info.active{background-color:#24748c \9;}
button.btn,input[type="submit"].btn{*padding-top:2px;*padding-bottom:2px;}button.btn::-moz-focus-inner,input[type="submit"].btn::-moz-focus-inner{padding:0;border:0;}
button.btn.large,input[type="submit"].btn.large{*padding-top:7px;*padding-bottom:7px;}
button.btn.small,input[type="submit"].btn.small{*padding-top:3px;*padding-bottom:3px;}
.btn-group{position:relative;*zoom:1;*margin-left:.3em;}.btn-group:before,.btn-group:after{display:table;content:"";}
.btn-group:after{clear:both;}
.btn-group:first-child{*margin-left:0;}
.btn-group+.btn-group{margin-left:5px;}
.btn-toolbar{margin-top:9px;margin-bottom:9px;}.btn-toolbar .btn-group{display:inline-block;*display:inline;*zoom:1;}
.btn-group .btn{position:relative;float:left;margin-left:-1px;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
.btn-group .btn:first-child{margin-left:0;-webkit-border-top-left-radius:4px;-moz-border-radius-topleft:4px;border-top-left-radius:4px;-webkit-border-bottom-left-radius:4px;-moz-border-radius-bottomleft:4px;border-bottom-left-radius:4px;}
.btn-group .btn:last-child,.btn-group .dropdown-toggle{-webkit-border-top-right-radius:4px;-moz-border-radius-topright:4px;border-top-right-radius:4px;-webkit-border-bottom-right-radius:4px;-moz-border-radius-bottomright:4px;border-bottom-right-radius:4px;}
.btn-group .btn.large:first-child{margin-left:0;-webkit-border-top-left-radius:6px;-moz-border-radius-topleft:6px;border-top-left-radius:6px;-webkit-border-bottom-left-radius:6px;-moz-border-radius-bottomleft:6px;border-bottom-left-radius:6px;}
.btn-group .btn.large:last-child,.btn-group .large.dropdown-toggle{-webkit-border-top-right-radius:6px;-moz-border-radius-topright:6px;border-top-right-radius:6px;-webkit-border-bottom-right-radius:6px;-moz-border-radius-bottomright:6px;border-bottom-right-radius:6px;}
.btn-group .btn:hover,.btn-group .btn:focus,.btn-group .btn:active,.btn-group .btn.active{z-index:2;}
.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0;}
.btn-group .dropdown-toggle{padding-left:8px;padding-right:8px;-webkit-box-shadow:inset 1px 0 0 rgba(255, 255, 255, 0.125),inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 1px 0 0 rgba(255, 255, 255, 0.125),inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 1px 0 0 rgba(255, 255, 255, 0.125),inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);*padding-top:5px;*padding-bottom:5px;}
.btn-group.open{*z-index:1000;}.btn-group.open .dropdown-menu{display:block;margin-top:1px;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;}
.btn-group.open .dropdown-toggle{background-image:none;-webkit-box-shadow:inset 0 1px 6px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 6px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 6px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);}
.btn .caret{margin-top:7px;margin-left:0;}
.btn:hover .caret,.open.btn-group .caret{opacity:1;filter:alpha(opacity=100);}
.btn-primary .caret,.btn-danger .caret,.btn-info .caret,.btn-success .caret{border-top-color:#ffffff;opacity:0.75;filter:alpha(opacity=75);}
.btn-small .caret{margin-top:4px;}
.alert{padding:8px 35px 8px 14px;margin-bottom:18px;text-shadow:0 1px 0 rgba(255, 255, 255, 0.5);background-color:#fcf8e3;border:1px solid #fbeed5;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.alert,.alert-heading{color:#c09853;}
.alert .close{position:relative;top:-2px;right:-21px;line-height:18px;}
.alert-success{background-color:#dff0d8;border-color:#d6e9c6;}
.alert-success,.alert-success .alert-heading{color:#468847;}
.alert-danger,.alert-error{background-color:#f2dede;border-color:#eed3d7;}
.alert-danger,.alert-error,.alert-danger .alert-heading,.alert-error .alert-heading{color:#b94a48;}
.alert-info{background-color:#d9edf7;border-color:#bce8f1;}
.alert-info,.alert-info .alert-heading{color:#3a87ad;}
.alert-block{padding-top:14px;padding-bottom:14px;}
.alert-block>p,.alert-block>ul{margin-bottom:0;}
.alert-block p+p{margin-top:5px;}
.nav{margin-left:0;margin-bottom:18px;list-style:none;}
.nav>li>a{display:block;}
.nav>li>a:hover{text-decoration:none;background-color:#eeeeee;}
.nav-list{padding-left:14px;padding-right:14px;margin-bottom:0;}
.nav-list>li>a,.nav-list .nav-header{display:block;padding:3px 15px;margin-left:-15px;margin-right:-15px;text-shadow:0 1px 0 rgba(255, 255, 255, 0.5);}
.nav-list .nav-header{font-size:11px;font-weight:bold;line-height:18px;color:#999999;text-transform:uppercase;}
.nav-list>li+.nav-header{margin-top:9px;}
.nav-list .active>a,.nav-list .active>a:hover{color:#ffffff;text-shadow:0 -1px 0 rgba(0, 0, 0, 0.2);background-color:#0088cc;}
.nav-list [class^="icon-"]{margin-right:2px;}
.nav-tabs,.nav-pills{*zoom:1;}.nav-tabs:before,.nav-pills:before,.nav-tabs:after,.nav-pills:after{display:table;content:"";}
.nav-tabs:after,.nav-pills:after{clear:both;}
.nav-tabs>li,.nav-pills>li{float:left;}
.nav-tabs>li>a,.nav-pills>li>a{padding-right:12px;padding-left:12px;margin-right:2px;line-height:14px;}
.nav-tabs{border-bottom:1px solid #ddd;}
.nav-tabs>li{margin-bottom:-1px;}
.nav-tabs>li>a{padding-top:9px;padding-bottom:9px;border:1px solid transparent;-webkit-border-radius:4px 4px 0 0;-moz-border-radius:4px 4px 0 0;border-radius:4px 4px 0 0;}.nav-tabs>li>a:hover{border-color:#eeeeee #eeeeee #dddddd;}
.nav-tabs>.active>a,.nav-tabs>.active>a:hover{color:#555555;background-color:#ffffff;border:1px solid #ddd;border-bottom-color:transparent;cursor:default;}
.nav-pills>li>a{padding-top:8px;padding-bottom:8px;margin-top:2px;margin-bottom:2px;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;}
.nav-pills .active>a,.nav-pills .active>a:hover{color:#ffffff;background-color:#0088cc;}
.nav-stacked>li{float:none;}
.nav-stacked>li>a{margin-right:0;}
.nav-tabs.nav-stacked{border-bottom:0;}
.nav-tabs.nav-stacked>li>a{border:1px solid #ddd;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
.nav-tabs.nav-stacked>li:first-child>a{-webkit-border-radius:4px 4px 0 0;-moz-border-radius:4px 4px 0 0;border-radius:4px 4px 0 0;}
.nav-tabs.nav-stacked>li:last-child>a{-webkit-border-radius:0 0 4px 4px;-moz-border-radius:0 0 4px 4px;border-radius:0 0 4px 4px;}
.nav-tabs.nav-stacked>li>a:hover{border-color:#ddd;z-index:2;}
.nav-pills.nav-stacked>li>a{margin-bottom:3px;}
.nav-pills.nav-stacked>li:last-child>a{margin-bottom:1px;}
.nav-tabs .dropdown-menu,.nav-pills .dropdown-menu{margin-top:1px;border-width:1px;}
.nav-pills .dropdown-menu{-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.nav-tabs .dropdown-toggle .caret,.nav-pills .dropdown-toggle .caret{border-top-color:#0088cc;margin-top:6px;}
.nav-tabs .dropdown-toggle:hover .caret,.nav-pills .dropdown-toggle:hover .caret{border-top-color:#005580;}
.nav-tabs .active .dropdown-toggle .caret,.nav-pills .active .dropdown-toggle .caret{border-top-color:#333333;}
.nav>.dropdown.active>a:hover{color:#000000;cursor:pointer;}
.nav-tabs .open .dropdown-toggle,.nav-pills .open .dropdown-toggle,.nav>.open.active>a:hover{color:#ffffff;background-color:#999999;border-color:#999999;}
.nav .open .caret,.nav .open.active .caret,.nav .open a:hover .caret{border-top-color:#ffffff;opacity:1;filter:alpha(opacity=100);}
.tabs-stacked .open>a:hover{border-color:#999999;}
.tabbable{*zoom:1;}.tabbable:before,.tabbable:after{display:table;content:"";}
.tabbable:after{clear:both;}
.tabs-below .nav-tabs,.tabs-right .nav-tabs,.tabs-left .nav-tabs{border-bottom:0;}
.tab-content>.tab-pane,.pill-content>.pill-pane{display:none;}
.tab-content>.active,.pill-content>.active{display:block;}
.tabs-below .nav-tabs{border-top:1px solid #ddd;}
.tabs-below .nav-tabs>li{margin-top:-1px;margin-bottom:0;}
.tabs-below .nav-tabs>li>a{-webkit-border-radius:0 0 4px 4px;-moz-border-radius:0 0 4px 4px;border-radius:0 0 4px 4px;}.tabs-below .nav-tabs>li>a:hover{border-bottom-color:transparent;border-top-color:#ddd;}
.tabs-below .nav-tabs .active>a,.tabs-below .nav-tabs .active>a:hover{border-color:transparent #ddd #ddd #ddd;}
.tabs-left .nav-tabs>li,.tabs-right .nav-tabs>li{float:none;}
.tabs-left .nav-tabs>li>a,.tabs-right .nav-tabs>li>a{min-width:74px;margin-right:0;margin-bottom:3px;}
.tabs-left .nav-tabs{float:left;margin-right:19px;border-right:1px solid #ddd;}
.tabs-left .nav-tabs>li>a{margin-right:-1px;-webkit-border-radius:4px 0 0 4px;-moz-border-radius:4px 0 0 4px;border-radius:4px 0 0 4px;}
.tabs-left .nav-tabs>li>a:hover{border-color:#eeeeee #dddddd #eeeeee #eeeeee;}
.tabs-left .nav-tabs .active>a,.tabs-left .nav-tabs .active>a:hover{border-color:#ddd transparent #ddd #ddd;*border-right-color:#ffffff;}
.tabs-right .nav-tabs{float:right;margin-left:19px;border-left:1px solid #ddd;}
.tabs-right .nav-tabs>li>a{margin-left:-1px;-webkit-border-radius:0 4px 4px 0;-moz-border-radius:0 4px 4px 0;border-radius:0 4px 4px 0;}
.tabs-right .nav-tabs>li>a:hover{border-color:#eeeeee #eeeeee #eeeeee #dddddd;}
.tabs-right .nav-tabs .active>a,.tabs-right .nav-tabs .active>a:hover{border-color:#ddd #ddd #ddd transparent;*border-left-color:#ffffff;}
.navbar{overflow:visible;margin-bottom:18px;}
.navbar-inner{padding-left:20px;padding-right:20px;background-color:#2c2c2c;background-image:-moz-linear-gradient(top, #333333, #222222);background-image:-ms-linear-gradient(top, #333333, #222222);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#333333), to(#222222));background-image:-webkit-linear-gradient(top, #333333, #222222);background-image:-o-linear-gradient(top, #333333, #222222);background-image:linear-gradient(top, #333333, #222222);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#333333', endColorstr='#222222', GradientType=0);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);-moz-box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);}
.btn-navbar{display:none;float:right;padding:7px 10px;margin-left:5px;margin-right:5px;background-color:#2c2c2c;background-image:-moz-linear-gradient(top, #333333, #222222);background-image:-ms-linear-gradient(top, #333333, #222222);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#333333), to(#222222));background-image:-webkit-linear-gradient(top, #333333, #222222);background-image:-o-linear-gradient(top, #333333, #222222);background-image:linear-gradient(top, #333333, #222222);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#333333', endColorstr='#222222', GradientType=0);border-color:#222222 #222222 #000000;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.075);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.075);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.075);}.btn-navbar:hover,.btn-navbar:active,.btn-navbar.active,.btn-navbar.disabled,.btn-navbar[disabled]{background-color:#222222;}
.btn-navbar:active,.btn-navbar.active{background-color:#080808 \9;}
.btn-navbar .icon-bar{display:block;width:18px;height:2px;background-color:#f5f5f5;-webkit-border-radius:1px;-moz-border-radius:1px;border-radius:1px;-webkit-box-shadow:0 1px 0 rgba(0, 0, 0, 0.25);-moz-box-shadow:0 1px 0 rgba(0, 0, 0, 0.25);box-shadow:0 1px 0 rgba(0, 0, 0, 0.25);}
.btn-navbar .icon-bar+.icon-bar{margin-top:3px;}
.nav-collapse.collapse{height:auto;}
.navbar .brand:hover{text-decoration:none;}
.navbar .brand{float:left;display:block;padding:8px 20px 12px;margin-left:-20px;font-size:20px;font-weight:200;line-height:1;color:#ffffff;}
.navbar .navbar-text{margin-bottom:0;line-height:40px;color:#999999;}.navbar .navbar-text a:hover{color:#ffffff;background-color:transparent;}
.navbar .btn,.navbar .btn-group{margin-top:5px;}
.navbar .btn-group .btn{margin-top:0;}
.navbar-form{margin-bottom:0;*zoom:1;}.navbar-form:before,.navbar-form:after{display:table;content:"";}
.navbar-form:after{clear:both;}
.navbar-form input,.navbar-form select{display:inline-block;margin-top:5px;margin-bottom:0;}
.navbar-form .radio,.navbar-form .checkbox{margin-top:5px;}
.navbar-form input[type="image"],.navbar-form input[type="checkbox"],.navbar-form input[type="radio"]{margin-top:3px;}
.navbar-search{position:relative;float:left;margin-top:6px;margin-bottom:0;}.navbar-search .search-query{padding:4px 9px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;font-weight:normal;line-height:1;color:#ffffff;color:rgba(255, 255, 255, 0.75);background:#666;background:rgba(255, 255, 255, 0.3);border:1px solid #111;-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.15);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.15);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.15);-webkit-transition:none;-moz-transition:none;-ms-transition:none;-o-transition:none;transition:none;}.navbar-search .search-query :-moz-placeholder{color:#eeeeee;}
.navbar-search .search-query::-webkit-input-placeholder{color:#eeeeee;}
.navbar-search .search-query:hover{color:#ffffff;background-color:#999999;background-color:rgba(255, 255, 255, 0.5);}
.navbar-search .search-query:focus,.navbar-search .search-query.focused{padding:5px 10px;color:#333333;text-shadow:0 1px 0 #ffffff;background-color:#ffffff;border:0;-webkit-box-shadow:0 0 3px rgba(0, 0, 0, 0.15);-moz-box-shadow:0 0 3px rgba(0, 0, 0, 0.15);box-shadow:0 0 3px rgba(0, 0, 0, 0.15);outline:0;}
.navbar-fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030;}
.navbar-fixed-top .navbar-inner{padding-left:0;padding-right:0;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
.navbar .nav{position:relative;left:0;display:block;float:left;margin:0 10px 0 0;}
.navbar .nav.pull-right{float:right;}
.navbar .nav>li{display:block;float:left;}
.navbar .nav>li>a{float:none;padding:10px 10px 11px;line-height:19px;color:#999999;text-decoration:none;text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);}
.navbar .nav>li>a:hover{background-color:transparent;color:#ffffff;text-decoration:none;}
.navbar .nav .active>a,.navbar .nav .active>a:hover{color:#ffffff;text-decoration:none;background-color:#222222;background-color:rgba(0, 0, 0, 0.5);}
.navbar .divider-vertical{height:40px;width:1px;margin:0 9px;overflow:hidden;background-color:#222222;border-right:1px solid #333333;}
.navbar .nav.pull-right{margin-left:10px;margin-right:0;}
.navbar .dropdown-menu{margin-top:1px;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}.navbar .dropdown-menu:before{content:'';display:inline-block;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:7px solid #ccc;border-bottom-color:rgba(0, 0, 0, 0.2);position:absolute;top:-7px;left:9px;}
.navbar .dropdown-menu:after{content:'';display:inline-block;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid #ffffff;position:absolute;top:-6px;left:10px;}
.navbar .nav .dropdown-toggle .caret,.navbar .nav .open.dropdown .caret{border-top-color:#ffffff;}
.navbar .nav .active .caret{opacity:1;filter:alpha(opacity=100);}
.navbar .nav .open>.dropdown-toggle,.navbar .nav .active>.dropdown-toggle,.navbar .nav .open.active>.dropdown-toggle{background-color:transparent;}
.navbar .nav .active>.dropdown-toggle:hover{color:#ffffff;}
.navbar .nav.pull-right .dropdown-menu{left:auto;right:0;}.navbar .nav.pull-right .dropdown-menu:before{left:auto;right:12px;}
.navbar .nav.pull-right .dropdown-menu:after{left:auto;right:13px;}
.breadcrumb{padding:7px 14px;margin:0 0 18px;background-color:#fbfbfb;background-image:-moz-linear-gradient(top, #ffffff, #f5f5f5);background-image:-ms-linear-gradient(top, #ffffff, #f5f5f5);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#f5f5f5));background-image:-webkit-linear-gradient(top, #ffffff, #f5f5f5);background-image:-o-linear-gradient(top, #ffffff, #f5f5f5);background-image:linear-gradient(top, #ffffff, #f5f5f5);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#f5f5f5', GradientType=0);border:1px solid #ddd;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;-webkit-box-shadow:inset 0 1px 0 #ffffff;-moz-box-shadow:inset 0 1px 0 #ffffff;box-shadow:inset 0 1px 0 #ffffff;}.breadcrumb li{display:inline;text-shadow:0 1px 0 #ffffff;}
.breadcrumb .divider{padding:0 5px;color:#999999;}
.breadcrumb .active a{color:#333333;}
.pagination{height:36px;margin:18px 0;}
.pagination ul{display:inline-block;*display:inline;*zoom:1;margin-left:0;margin-bottom:0;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;-webkit-box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);}
.pagination li{display:inline;}
.pagination a{float:left;padding:0 14px;line-height:34px;text-decoration:none;border:1px solid #ddd;border-left-width:0;}
.pagination a:hover,.pagination .active a{background-color:#f5f5f5;}
.pagination .active a{color:#999999;cursor:default;}
.pagination .disabled a,.pagination .disabled a:hover{color:#999999;background-color:transparent;cursor:default;}
.pagination li:first-child a{border-left-width:1px;-webkit-border-radius:3px 0 0 3px;-moz-border-radius:3px 0 0 3px;border-radius:3px 0 0 3px;}
.pagination li:last-child a{-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;}
.pagination-centered{text-align:center;}
.pagination-right{text-align:right;}
.pager{margin-left:0;margin-bottom:18px;list-style:none;text-align:center;*zoom:1;}.pager:before,.pager:after{display:table;content:"";}
.pager:after{clear:both;}
.pager li{display:inline;}
.pager a{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;-webkit-border-radius:15px;-moz-border-radius:15px;border-radius:15px;}
.pager a:hover{text-decoration:none;background-color:#f5f5f5;}
.pager .next a{float:right;}
.pager .previous a{float:left;}
.modal-open .dropdown-menu{z-index:2050;}
.modal-open .dropdown.open{*z-index:2050;}
.modal-open .popover{z-index:2060;}
.modal-open .tooltip{z-index:2070;}
.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000000;}.modal-backdrop.fade{opacity:0;}
.modal-backdrop,.modal-backdrop.fade.in{opacity:0.8;filter:alpha(opacity=80);}
.modal{position:fixed;top:50%;left:50%;z-index:1050;max-height:500px;overflow:auto;width:560px;margin:-250px 0 0 -280px;background-color:#ffffff;border:1px solid #999;border:1px solid rgba(0, 0, 0, 0.3);*border:1px solid #999;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-moz-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;}.modal.fade{-webkit-transition:opacity .3s linear, top .3s ease-out;-moz-transition:opacity .3s linear, top .3s ease-out;-ms-transition:opacity .3s linear, top .3s ease-out;-o-transition:opacity .3s linear, top .3s ease-out;transition:opacity .3s linear, top .3s ease-out;top:-25%;}
.modal.fade.in{top:50%;}
.modal-header{padding:9px 15px;border-bottom:1px solid #eee;}.modal-header .close{margin-top:2px;}
.modal-body{padding:15px;}
.modal-footer{padding:14px 15px 15px;margin-bottom:0;background-color:#f5f5f5;border-top:1px solid #ddd;-webkit-border-radius:0 0 6px 6px;-moz-border-radius:0 0 6px 6px;border-radius:0 0 6px 6px;-webkit-box-shadow:inset 0 1px 0 #ffffff;-moz-box-shadow:inset 0 1px 0 #ffffff;box-shadow:inset 0 1px 0 #ffffff;*zoom:1;}.modal-footer:before,.modal-footer:after{display:table;content:"";}
.modal-footer:after{clear:both;}
.modal-footer .btn{float:right;margin-left:5px;margin-bottom:0;}
.tooltip{position:absolute;z-index:1020;display:block;visibility:visible;padding:5px;font-size:11px;opacity:0;filter:alpha(opacity=0);}.tooltip.in{opacity:0.8;filter:alpha(opacity=80);}
.tooltip.top{margin-top:-2px;}
.tooltip.right{margin-left:2px;}
.tooltip.bottom{margin-top:2px;}
.tooltip.left{margin-left:-2px;}
.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid #000000;}
.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-left:5px solid #000000;}
.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:5px solid #000000;}
.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-right:5px solid #000000;}
.tooltip-inner{max-width:200px;padding:3px 8px;color:#ffffff;text-align:center;text-decoration:none;background-color:#000000;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.tooltip-arrow{position:absolute;width:0;height:0;}
.popover{position:absolute;top:0;left:0;z-index:1010;display:none;padding:5px;}.popover.top{margin-top:-5px;}
.popover.right{margin-left:5px;}
.popover.bottom{margin-top:5px;}
.popover.left{margin-left:-5px;}
.popover.top .arrow{bottom:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid #000000;}
.popover.right .arrow{top:50%;left:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-right:5px solid #000000;}
.popover.bottom .arrow{top:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:5px solid #000000;}
.popover.left .arrow{top:50%;right:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-left:5px solid #000000;}
.popover .arrow{position:absolute;width:0;height:0;}
.popover-inner{padding:3px;width:280px;overflow:hidden;background:#000000;background:rgba(0, 0, 0, 0.8);-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-moz-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);}
.popover-title{padding:9px 15px;line-height:1;background-color:#f5f5f5;border-bottom:1px solid #eee;-webkit-border-radius:3px 3px 0 0;-moz-border-radius:3px 3px 0 0;border-radius:3px 3px 0 0;}
.popover-content{padding:14px;background-color:#ffffff;-webkit-border-radius:0 0 3px 3px;-moz-border-radius:0 0 3px 3px;border-radius:0 0 3px 3px;-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;}.popover-content p,.popover-content ul,.popover-content ol{margin-bottom:0;}
.thumbnails{margin-left:-20px;list-style:none;*zoom:1;}.thumbnails:before,.thumbnails:after{display:table;content:"";}
.thumbnails:after{clear:both;}
.thumbnails>li{float:left;margin:0 0 18px 20px;}
.thumbnail{display:block;padding:4px;line-height:1;border:1px solid #ddd;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);-moz-box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);}
a.thumbnail:hover{border-color:#0088cc;-webkit-box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);-moz-box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);}
.thumbnail>img{display:block;max-width:100%;margin-left:auto;margin-right:auto;}
.thumbnail .caption{padding:9px;}
.label{padding:1px 3px 2px;font-size:9.75px;font-weight:bold;color:#ffffff;text-transform:uppercase;background-color:#999999;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}
.label-important{background-color:#b94a48;}
.label-warning{background-color:#f89406;}
.label-success{background-color:#468847;}
.label-info{background-color:#3a87ad;}
@-webkit-keyframes progress-bar-stripes{from{background-position:0 0;} to{background-position:40px 0;}}@-moz-keyframes progress-bar-stripes{from{background-position:0 0;} to{background-position:40px 0;}}@keyframes progress-bar-stripes{from{background-position:0 0;} to{background-position:40px 0;}}.progress{overflow:hidden;height:18px;margin-bottom:18px;background-color:#f7f7f7;background-image:-moz-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:-ms-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#f5f5f5), to(#f9f9f9));background-image:-webkit-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:-o-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:linear-gradient(top, #f5f5f5, #f9f9f9);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#f5f5f5', endColorstr='#f9f9f9', GradientType=0);-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.progress .bar{width:0%;height:18px;color:#ffffff;font-size:12px;text-align:center;text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);background-color:#0e90d2;background-image:-moz-linear-gradient(top, #149bdf, #0480be);background-image:-ms-linear-gradient(top, #149bdf, #0480be);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#149bdf), to(#0480be));background-image:-webkit-linear-gradient(top, #149bdf, #0480be);background-image:-o-linear-gradient(top, #149bdf, #0480be);background-image:linear-gradient(top, #149bdf, #0480be);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#149bdf', endColorstr='#0480be', GradientType=0);-webkit-box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);-moz-box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;-webkit-transition:width 0.6s ease;-moz-transition:width 0.6s ease;-ms-transition:width 0.6s ease;-o-transition:width 0.6s ease;transition:width 0.6s ease;}
.progress-striped .bar{background-color:#62c462;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);-webkit-background-size:40px 40px;-moz-background-size:40px 40px;-o-background-size:40px 40px;background-size:40px 40px;}
.progress.active .bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-moz-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite;}
.progress-danger .bar{background-color:#dd514c;background-image:-moz-linear-gradient(top, #ee5f5b, #c43c35);background-image:-ms-linear-gradient(top, #ee5f5b, #c43c35);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ee5f5b), to(#c43c35));background-image:-webkit-linear-gradient(top, #ee5f5b, #c43c35);background-image:-o-linear-gradient(top, #ee5f5b, #c43c35);background-image:linear-gradient(top, #ee5f5b, #c43c35);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#c43c35', GradientType=0);}
.progress-danger.progress-striped .bar{background-color:#ee5f5b;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);}
.progress-success .bar{background-color:#5eb95e;background-image:-moz-linear-gradient(top, #62c462, #57a957);background-image:-ms-linear-gradient(top, #62c462, #57a957);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#62c462), to(#57a957));background-image:-webkit-linear-gradient(top, #62c462, #57a957);background-image:-o-linear-gradient(top, #62c462, #57a957);background-image:linear-gradient(top, #62c462, #57a957);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#62c462', endColorstr='#57a957', GradientType=0);}
.progress-success.progress-striped .bar{background-color:#62c462;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);}
.progress-info .bar{background-color:#4bb1cf;background-image:-moz-linear-gradient(top, #5bc0de, #339bb9);background-image:-ms-linear-gradient(top, #5bc0de, #339bb9);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#5bc0de), to(#339bb9));background-image:-webkit-linear-gradient(top, #5bc0de, #339bb9);background-image:-o-linear-gradient(top, #5bc0de, #339bb9);background-image:linear-gradient(top, #5bc0de, #339bb9);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#5bc0de', endColorstr='#339bb9', GradientType=0);}
.progress-info.progress-striped .bar{background-color:#5bc0de;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);}
.accordion{margin-bottom:18px;}
.accordion-group{margin-bottom:2px;border:1px solid #e5e5e5;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
.accordion-heading{border-bottom:0;}
.accordion-heading .accordion-toggle{display:block;padding:8px 15px;}
.accordion-inner{padding:9px 15px;border-top:1px solid #e5e5e5;}
.carousel{position:relative;margin-bottom:18px;line-height:1;}
.carousel-inner{overflow:hidden;width:100%;position:relative;}
.carousel .item{display:none;position:relative;-webkit-transition:0.6s ease-in-out left;-moz-transition:0.6s ease-in-out left;-ms-transition:0.6s ease-in-out left;-o-transition:0.6s ease-in-out left;transition:0.6s ease-in-out left;}
.carousel .item>img{display:block;line-height:1;}
.carousel .active,.carousel .next,.carousel .prev{display:block;}
.carousel .active{left:0;}
.carousel .next,.carousel .prev{position:absolute;top:0;width:100%;}
.carousel .next{left:100%;}
.carousel .prev{left:-100%;}
.carousel .next.left,.carousel .prev.right{left:0;}
.carousel .active.left{left:-100%;}
.carousel .active.right{left:100%;}
.carousel-control{position:absolute;top:40%;left:15px;width:40px;height:40px;margin-top:-20px;font-size:60px;font-weight:100;line-height:30px;color:#ffffff;text-align:center;background:#222222;border:3px solid #ffffff;-webkit-border-radius:23px;-moz-border-radius:23px;border-radius:23px;opacity:0.5;filter:alpha(opacity=50);}.carousel-control.right{left:auto;right:15px;}
.carousel-control:hover{color:#ffffff;text-decoration:none;opacity:0.9;filter:alpha(opacity=90);}
.carousel-caption{position:absolute;left:0;right:0;bottom:0;padding:10px 15px 5px;background:#333333;background:rgba(0, 0, 0, 0.75);}
.carousel-caption h4,.carousel-caption p{color:#ffffff;}
.hero-unit{padding:60px;margin-bottom:30px;background-color:#f5f5f5;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;}.hero-unit h1{margin-bottom:0;font-size:60px;line-height:1;letter-spacing:-1px;}
.hero-unit p{font-size:18px;font-weight:200;line-height:27px;}
.pull-right{float:right;}
.pull-left{float:left;}
.hide{display:none;}
.show{display:block;}
.invisible{visibility:hidden;}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

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

Before

Width:  |  Height:  |  Size: 923 B

-4
View File
@@ -1,4 +0,0 @@
/*! jQuery v1.7.1 jquery.com | jquery.org/license */
(function(a,b){function cy(a){return f.isWindow(a)?a:a.nodeType===9?a.defaultView||a.parentWindow:!1}function cv(a){if(!ck[a]){var b=c.body,d=f("<"+a+">").appendTo(b),e=d.css("display");d.remove();if(e==="none"||e===""){cl||(cl=c.createElement("iframe"),cl.frameBorder=cl.width=cl.height=0),b.appendChild(cl);if(!cm||!cl.createElement)cm=(cl.contentWindow||cl.contentDocument).document,cm.write((c.compatMode==="CSS1Compat"?"<!doctype html>":"")+"<html><body>"),cm.close();d=cm.createElement(a),cm.body.appendChild(d),e=f.css(d,"display"),b.removeChild(cl)}ck[a]=e}return ck[a]}function cu(a,b){var c={};f.each(cq.concat.apply([],cq.slice(0,b)),function(){c[this]=a});return c}function ct(){cr=b}function cs(){setTimeout(ct,0);return cr=f.now()}function cj(){try{return new a.ActiveXObject("Microsoft.XMLHTTP")}catch(b){}}function ci(){try{return new a.XMLHttpRequest}catch(b){}}function cc(a,c){a.dataFilter&&(c=a.dataFilter(c,a.dataType));var d=a.dataTypes,e={},g,h,i=d.length,j,k=d[0],l,m,n,o,p;for(g=1;g<i;g++){if(g===1)for(h in a.converters)typeof h=="string"&&(e[h.toLowerCase()]=a.converters[h]);l=k,k=d[g];if(k==="*")k=l;else if(l!=="*"&&l!==k){m=l+" "+k,n=e[m]||e["* "+k];if(!n){p=b;for(o in e){j=o.split(" ");if(j[0]===l||j[0]==="*"){p=e[j[1]+" "+k];if(p){o=e[o],o===!0?n=p:p===!0&&(n=o);break}}}}!n&&!p&&f.error("No conversion from "+m.replace(" "," to ")),n!==!0&&(c=n?n(c):p(o(c)))}}return c}function cb(a,c,d){var e=a.contents,f=a.dataTypes,g=a.responseFields,h,i,j,k;for(i in g)i in d&&(c[g[i]]=d[i]);while(f[0]==="*")f.shift(),h===b&&(h=a.mimeType||c.getResponseHeader("content-type"));if(h)for(i in e)if(e[i]&&e[i].test(h)){f.unshift(i);break}if(f[0]in d)j=f[0];else{for(i in d){if(!f[0]||a.converters[i+" "+f[0]]){j=i;break}k||(k=i)}j=j||k}if(j){j!==f[0]&&f.unshift(j);return d[j]}}function ca(a,b,c,d){if(f.isArray(b))f.each(b,function(b,e){c||bE.test(a)?d(a,e):ca(a+"["+(typeof e=="object"||f.isArray(e)?b:"")+"]",e,c,d)});else if(!c&&b!=null&&typeof b=="object")for(var e in b)ca(a+"["+e+"]",b[e],c,d);else d(a,b)}function b_(a,c){var d,e,g=f.ajaxSettings.flatOptions||{};for(d in c)c[d]!==b&&((g[d]?a:e||(e={}))[d]=c[d]);e&&f.extend(!0,a,e)}function b$(a,c,d,e,f,g){f=f||c.dataTypes[0],g=g||{},g[f]=!0;var h=a[f],i=0,j=h?h.length:0,k=a===bT,l;for(;i<j&&(k||!l);i++)l=h[i](c,d,e),typeof l=="string"&&(!k||g[l]?l=b:(c.dataTypes.unshift(l),l=b$(a,c,d,e,l,g)));(k||!l)&&!g["*"]&&(l=b$(a,c,d,e,"*",g));return l}function bZ(a){return function(b,c){typeof b!="string"&&(c=b,b="*");if(f.isFunction(c)){var d=b.toLowerCase().split(bP),e=0,g=d.length,h,i,j;for(;e<g;e++)h=d[e],j=/^\+/.test(h),j&&(h=h.substr(1)||"*"),i=a[h]=a[h]||[],i[j?"unshift":"push"](c)}}}function bC(a,b,c){var d=b==="width"?a.offsetWidth:a.offsetHeight,e=b==="width"?bx:by,g=0,h=e.length;if(d>0){if(c!=="border")for(;g<h;g++)c||(d-=parseFloat(f.css(a,"padding"+e[g]))||0),c==="margin"?d+=parseFloat(f.css(a,c+e[g]))||0:d-=parseFloat(f.css(a,"border"+e[g]+"Width"))||0;return d+"px"}d=bz(a,b,b);if(d<0||d==null)d=a.style[b]||0;d=parseFloat(d)||0;if(c)for(;g<h;g++)d+=parseFloat(f.css(a,"padding"+e[g]))||0,c!=="padding"&&(d+=parseFloat(f.css(a,"border"+e[g]+"Width"))||0),c==="margin"&&(d+=parseFloat(f.css(a,c+e[g]))||0);return d+"px"}function bp(a,b){b.src?f.ajax({url:b.src,async:!1,dataType:"script"}):f.globalEval((b.text||b.textContent||b.innerHTML||"").replace(bf,"/*$0*/")),b.parentNode&&b.parentNode.removeChild(b)}function bo(a){var b=c.createElement("div");bh.appendChild(b),b.innerHTML=a.outerHTML;return b.firstChild}function bn(a){var b=(a.nodeName||"").toLowerCase();b==="input"?bm(a):b!=="script"&&typeof a.getElementsByTagName!="undefined"&&f.grep(a.getElementsByTagName("input"),bm)}function bm(a){if(a.type==="checkbox"||a.type==="radio")a.defaultChecked=a.checked}function bl(a){return typeof a.getElementsByTagName!="undefined"?a.getElementsByTagName("*"):typeof a.querySelectorAll!="undefined"?a.querySelectorAll("*"):[]}function bk(a,b){var c;if(b.nodeType===1){b.clearAttributes&&b.clearAttributes(),b.mergeAttributes&&b.mergeAttributes(a),c=b.nodeName.toLowerCase();if(c==="object")b.outerHTML=a.outerHTML;else if(c!=="input"||a.type!=="checkbox"&&a.type!=="radio"){if(c==="option")b.selected=a.defaultSelected;else if(c==="input"||c==="textarea")b.defaultValue=a.defaultValue}else a.checked&&(b.defaultChecked=b.checked=a.checked),b.value!==a.value&&(b.value=a.value);b.removeAttribute(f.expando)}}function bj(a,b){if(b.nodeType===1&&!!f.hasData(a)){var c,d,e,g=f._data(a),h=f._data(b,g),i=g.events;if(i){delete h.handle,h.events={};for(c in i)for(d=0,e=i[c].length;d<e;d++)f.event.add(b,c+(i[c][d].namespace?".":"")+i[c][d].namespace,i[c][d],i[c][d].data)}h.data&&(h.data=f.extend({},h.data))}}function bi(a,b){return f.nodeName(a,"table")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function U(a){var b=V.split("|"),c=a.createDocumentFragment();if(c.createElement)while(b.length)c.createElement(b.pop());return c}function T(a,b,c){b=b||0;iLine truncated
f.event={add:function(a,c,d,e,g){var h,i,j,k,l,m,n,o,p,q,r,s;if(!(a.nodeType===3||a.nodeType===8||!c||!d||!(h=f._data(a)))){d.handler&&(p=d,d=p.handler),d.guid||(d.guid=f.guid++),j=h.events,j||(h.events=j={}),i=h.handle,i||(h.handle=i=function(a){return typeof f!="undefined"&&(!a||f.event.triggered!==a.type)?f.event.dispatch.apply(i.elem,arguments):b},i.elem=a),c=f.trim(I(c)).split(" ");for(k=0;k<c.length;k++){l=A.exec(c[k])||[],m=l[1],n=(l[2]||"").split(".").sort(),s=f.event.special[m]||{},m=(g?s.delegateType:s.bindType)||m,s=f.event.special[m]||{},o=f.extend({type:m,origType:l[1],data:e,handler:d,guid:d.guid,selector:g,quick:G(g),namespace:n.join(".")},p),r=j[m];if(!r){r=j[m]=[],r.delegateCount=0;if(!s.setup||s.setup.call(a,e,n,i)===!1)a.addEventListener?a.addEventListener(m,i,!1):a.attachEvent&&a.attachEvent("on"+m,i)}s.add&&(s.add.call(a,o),o.handler.guid||(o.handler.guid=d.guid)),g?r.splice(r.delegateCount++,0,o):r.push(o),f.event.global[m]=!0}a=null}},global:{},remove:function(a,b,c,d,e){var g=f.hasData(a)&&f._data(a),h,i,j,k,l,m,n,o,p,q,r,s;if(!!g&&!!(o=g.events)){b=f.trim(I(b||"")).split(" ");for(h=0;h<b.length;h++){i=A.exec(b[h])||[],j=k=i[1],l=i[2];if(!j){for(j in o)f.event.remove(a,j+b[h],c,d,!0);continue}p=f.event.special[j]||{},j=(d?p.delegateType:p.bindType)||j,r=o[j]||[],m=r.length,l=l?new RegExp("(^|\\.)"+l.split(".").sort().join("\\.(?:.*\\.)?")+"(\\.|$)"):null;for(n=0;n<r.length;n++)s=r[n],(e||k===s.origType)&&(!c||c.guid===s.guid)&&(!l||l.test(s.namespace))&&(!d||d===s.selector||d==="**"&&s.selector)&&(r.splice(n--,1),s.selector&&r.delegateCount--,p.remove&&p.remove.call(a,s));r.length===0&&m!==r.length&&((!p.teardown||p.teardown.call(a,l)===!1)&&f.removeEvent(a,j,g.handle),delete o[j])}f.isEmptyObject(o)&&(q=g.handle,q&&(q.elem=null),f.removeData(a,["events","handle"],!0))}},customEvent:{getData:!0,setData:!0,changeData:!0},trigger:function(c,d,e,g){if(!e||e.nodeType!==3&&e.nodeType!==8){var h=c.type||c,i=[],j,k,l,m,n,o,p,q,r,s;if(E.test(h+f.event.triggered))return;h.indexOf("!")>=0&&(h=h.slice(0,-1),k=!0),h.indexOf(".")>=0&&(i=h.split("."),h=i.shift(),i.sort());if((!e||f.event.customEvent[h])&&!f.event.global[h])return;c=typeof c=="object"?c[f.expando]?c:new f.Event(h,c):new f.Event(h),c.type=h,c.isTrigger=!0,c.exclusive=k,c.namespace=i.join("."),c.namespace_re=c.namespace?new RegExp("(^|\\.)"+i.join("\\.(?:.*\\.)?")+"(\\.|$)"):null,o=h.indexOf(":")<0?"on"+h:"";if(!e){j=f.cache;for(l in j)j[l].events&&j[l].events[h]&&f.event.trigger(c,d,j[l].handle.elem,!0);return}c.result=b,c.target||(c.target=e),d=d!=null?f.makeArray(d):[],d.unshift(c),p=f.event.special[h]||{};if(p.trigger&&p.trigger.apply(e,d)===!1)return;r=[[e,p.bindType||h]];if(!g&&!p.noBubble&&!f.isWindow(e)){s=p.delegateType||h,m=E.test(s+h)?e:e.parentNode,n=null;for(;m;m=m.parentNode)r.push([m,s]),n=m;n&&n===e.ownerDocument&&r.push([n.defaultView||n.parentWindow||a,s])}for(l=0;l<r.length&&!c.isPropagationStopped();l++)m=r[l][0],c.type=r[l][1],q=(f._data(m,"events")||{})[c.type]&&f._data(m,"handle"),q&&q.apply(m,d),q=o&&m[o],q&&f.acceptData(m)&&q.apply(m,d)===!1&&c.preventDefault();c.type=h,!g&&!c.isDefaultPrevented()&&(!p._default||p._default.apply(e.ownerDocument,d)===!1)&&(h!=="click"||!f.nodeName(e,"a"))&&f.acceptData(e)&&o&&e[h]&&(h!=="focus"&&h!=="blur"||c.target.offsetWidth!==0)&&!f.isWindow(e)&&(n=e[o],n&&(e[o]=null),f.event.triggered=h,e[h](),f.event.triggered=b,n&&(e[o]=n));return c.result}},dispatch:function(c){c=f.event.fix(c||a.event);var d=(f._data(this,"events")||{})[c.type]||[],e=d.delegateCount,g=[].slice.call(arguments,0),h=!c.exclusive&&!c.namespace,i=[],j,k,l,m,n,o,p,q,r,s,t;g[0]=c,c.delegateTarget=this;if(e&&!c.target.disabled&&(!c.button||c.type!=="click")){m=f(this),m.context=this.ownerDocument||this;for(l=c.target;l!=this;l=l.parentNode||this){o={},q=[],m[0]=l;for(j=0;j<e;j++)r=d[j],s=r.selector,o[s]===b&&(o[s]=r.quick?H(l,r.quick):m.is(s)),o[s]&&q.push(r);q.length&&i.push({elem:l,matches:q})}}d.length>e&&i.push({elem:this,matches:d.slice(e)});for(j=0;j<i.length&&!c.isPropagationStopped();j++){p=i[j],c.currentTarget=p.elem;for(k=0;k<p.matches.length&&!c.isImmediatePropagationStopped();k++){r=p.matches[k];if(h||!c.namespace&&!r.namespace||c.namespace_re&&c.namespace_re.test(r.namespace))c.data=r.data,c.handleObj=r,n=((f.event.special[r.origType]||{}).handle||r.handler).apply(p.elem,g),n!==b&&(c.result=n,n===!1&&(c.preventDefault(),c.stopPropagation()))}}return c.result},props:"attrChange attrName relatedNode srcElement altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(a,b){a.which==null&&(a.which=b.charCode!=null?b.charCode:b.keyCode);return a}},mouseHooks:{props:"button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(a,d){var e,f,g,h=d.button,i=d.fromElement;a.pageX==null&&d.cLine truncated
{for(var a=0,b;(b=this[a])!=null;a++){b.nodeType===1&&f.cleanData(b.getElementsByTagName("*"));while(b.firstChild)b.removeChild(b.firstChild)}return this},clone:function(a,b){a=a==null?!1:a,b=b==null?a:b;return this.map(function(){return f.clone(this,a,b)})},html:function(a){if(a===b)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(W,""):null;if(typeof a=="string"&&!ba.test(a)&&(f.support.leadingWhitespace||!X.test(a))&&!bg[(Z.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(Y,"<$1></$2>");try{for(var c=0,d=this.length;c<d;c++)this[c].nodeType===1&&(f.cleanData(this[c].getElementsByTagName("*")),this[c].innerHTML=a)}catch(e){this.empty().append(a)}}else f.isFunction(a)?this.each(function(b){var c=f(this);c.html(a.call(this,b,c.html()))}):this.empty().append(a);return this},replaceWith:function(a){if(this[0]&&this[0].parentNode){if(f.isFunction(a))return this.each(function(b){var c=f(this),d=c.html();c.replaceWith(a.call(this,b,d))});typeof a!="string"&&(a=f(a).detach());return this.each(function(){var b=this.nextSibling,c=this.parentNode;f(this).remove(),b?f(b).before(a):f(c).append(a)})}return this.length?this.pushStack(f(f.isFunction(a)?a():a),"replaceWith",a):this},detach:function(a){return this.remove(a,!0)},domManip:function(a,c,d){var e,g,h,i,j=a[0],k=[];if(!f.support.checkClone&&arguments.length===3&&typeof j=="string"&&bd.test(j))return this.each(function(){f(this).domManip(a,c,d,!0)});if(f.isFunction(j))return this.each(function(e){var g=f(this);a[0]=j.call(this,e,c?g.html():b),g.domManip(a,c,d)});if(this[0]){i=j&&j.parentNode,f.support.parentNode&&i&&i.nodeType===11&&i.childNodes.length===this.length?e={fragment:i}:e=f.buildFragment(a,this,k),h=e.fragment,h.childNodes.length===1?g=h=h.firstChild:g=h.firstChild;if(g){c=c&&f.nodeName(g,"tr");for(var l=0,m=this.length,n=m-1;l<m;l++)d.call(c?bi(this[l],g):this[l],e.cacheable||m>1&&l<n?f.clone(h,!0,!0):h)}k.length&&f.each(k,bp)}return this}}),f.buildFragment=function(a,b,d){var e,g,h,i,j=a[0];b&&b[0]&&(i=b[0].ownerDocument||b[0]),i.createDocumentFragment||(i=c),a.length===1&&typeof j=="string"&&j.length<512&&i===c&&j.charAt(0)==="<"&&!bb.test(j)&&(f.support.checkClone||!bd.test(j))&&(f.support.html5Clone||!bc.test(j))&&(g=!0,h=f.fragments[j],h&&h!==1&&(e=h)),e||(e=i.createDocumentFragment(),f.clean(a,i,e,d)),g&&(f.fragments[j]=h?e:1);return{fragment:e,cacheable:g}},f.fragments={},f.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){f.fn[a]=function(c){var d=[],e=f(c),g=this.length===1&&this[0].parentNode;if(g&&g.nodeType===11&&g.childNodes.length===1&&e.length===1){e[b](this[0]);return this}for(var h=0,i=e.length;h<i;h++){var j=(h>0?this.clone(!0):this).get();f(e[h])[b](j),d=d.concat(j)}return this.pushStack(d,a,e.selector)}}),f.extend({clone:function(a,b,c){var d,e,g,h=f.support.html5Clone||!bc.test("<"+a.nodeName)?a.cloneNode(!0):bo(a);if((!f.support.noCloneEvent||!f.support.noCloneChecked)&&(a.nodeType===1||a.nodeType===11)&&!f.isXMLDoc(a)){bk(a,h),d=bl(a),e=bl(h);for(g=0;d[g];++g)e[g]&&bk(d[g],e[g])}if(b){bj(a,h);if(c){d=bl(a),e=bl(h);for(g=0;d[g];++g)bj(d[g],e[g])}}d=e=null;return h},clean:function(a,b,d,e){var g;b=b||c,typeof b.createElement=="undefined"&&(b=b.ownerDocument||b[0]&&b[0].ownerDocument||c);var h=[],i;for(var j=0,k;(k=a[j])!=null;j++){typeof k=="number"&&(k+="");if(!k)continue;if(typeof k=="string")if(!_.test(k))k=b.createTextNode(k);else{k=k.replace(Y,"<$1></$2>");var l=(Z.exec(k)||["",""])[1].toLowerCase(),m=bg[l]||bg._default,n=m[0],o=b.createElement("div");b===c?bh.appendChild(o):U(b).appendChild(o),o.innerHTML=m[1]+k+m[2];while(n--)o=o.lastChild;if(!f.support.tbody){var p=$.test(k),q=l==="table"&&!p?o.firstChild&&o.firstChild.childNodes:m[1]==="<table>"&&!p?o.childNodes:[];for(i=q.length-1;i>=0;--i)f.nodeName(q[i],"tbody")&&!q[i].childNodes.length&&q[i].parentNode.removeChild(q[i])}!f.support.leadingWhitespace&&X.test(k)&&o.insertBefore(b.createTextNode(X.exec(k)[0]),o.firstChild),k=o.childNodes}var r;if(!f.support.appendChecked)if(k[0]&&typeof (r=k.length)=="number")for(i=0;i<r;i++)bn(k[i]);else bn(k);k.nodeType?h.push(k):h=f.merge(h,k)}if(d){g=function(a){return!a.type||be.test(a.type)};for(j=0;h[j];j++)if(e&&f.nodeName(h[j],"script")&&(!h[j].type||h[j].type.toLowerCase()==="text/javascript"))e.push(h[j].parentNode?h[j].parentNode.removeChild(h[j]):h[j]);else{if(h[j].nodeType===1){var s=f.grep(h[j].getElementsByTagName("script"),g);h.splice.apply(h,[j+1,0].concat(s))}d.appendChild(h[j])}}return h},cleanData:function(a){var b,c,d=f.cache,e=f.event.special,g=f.support.deleteExpando;for(var h=0,i;(i=a[h])!=null;h++){if(i.nodeName&&f.noData[i.nodeName.toLowerCase()])continue;c=i[f.expando];if(c){b=d[c];if(b&&b.events){for(var j in b.events)e[j]?f.event.remove(i,j):f.removeEvent(i,j,b.handle);b.handle&&(b.handle.elem=null)}g?delete i[f.expando]:i.removeAttribute&&i.removeAttribute(f.expando),delete d[c]}}}});var bq=/alpha\([^)]*\)/i,br=/opacitLine truncated
View File
Whitespace-only changes.
+55
View File
@@ -0,0 +1,55 @@
import pytest
from django.core.management import call_command
from mdb.models import (
Domain,
DomainAAAARecord,
DomainARecord,
DomainCnameRecord,
DomainTxtRecord,
MailExchange,
Nameserver,
)
from mdb.services.zone_sync import ZoneSyncService
@pytest.fixture
def domain(db):
call_command("loaddata", "test_data", verbosity=0)
return Domain.objects.get(domain_name="edb.technology")
@pytest.fixture
def service(tmp_path):
svc = ZoneSyncService(debug=True)
svc.checkzone_dir = str(tmp_path)
return svc
@pytest.fixture
def db_domain(db, tmp_path):
domain = Domain.objects.create(
domain_name="example.org",
domain_soa="ns1.example.org",
domain_admin="admin@example.org",
domain_ipaddr="1.2.3.4",
domain_ip6addr="2001:db8::1",
domain_filename=str(tmp_path / "db.example.org"),
domain_ttl=3600,
domain_serial=2024010101,
)
ns1 = Nameserver.objects.create(hostname="ns1.example.org")
ns2 = Nameserver.objects.create(hostname="ns2.example.org")
domain.domain_nameservers.set([ns1, ns2])
mx = MailExchange.objects.create(priority=10, hostname="mail.example.org")
domain.domain_mailexchanges.set([mx])
DomainARecord.objects.create(domain=domain, name="ns1", target="1.2.3.4")
DomainARecord.objects.create(domain=domain, name="ns2", target="5.6.7.8")
DomainARecord.objects.create(domain=domain, name="mail", target="9.10.11.12")
DomainARecord.objects.create(domain=domain, name="www", target="1.2.3.4")
DomainAAAARecord.objects.create(domain=domain, name="www", target="2001:db8::1")
DomainCnameRecord.objects.create(
domain=domain, name="ftp", target="www.example.org."
)
DomainTxtRecord.objects.create(domain=domain, name="@", target='"v=spf1 mx ~all"')
return domain
+43
View File
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,121 @@
from unittest.mock import MagicMock, patch
import pytest
from mdb.services.dhcp_sync import DhcpSyncService
def make_config(serial=2, active_serial=1):
config = MagicMock()
config.serial = serial
config.active_serial = active_serial
config.dhcpd_configuration.return_value = (
"# dhcpd config\nsubnet 10.0.0.0 netmask 255.255.255.0 {}\n"
)
return config
@pytest.fixture
def service():
return DhcpSyncService()
# --- needs_update ---
def test_needs_update_true_when_serials_differ(service):
config = make_config(serial=2, active_serial=1)
assert service.needs_update(config) is True
def test_needs_update_false_when_serials_match(service):
config = make_config(serial=1, active_serial=1)
assert service.needs_update(config) is False
def test_needs_update_true_when_force_even_if_serials_match():
svc = DhcpSyncService(force=True)
config = make_config(serial=1, active_serial=1)
assert svc.needs_update(config) is True
# --- calculate_diff ---
def test_calculate_diff_identical_returns_empty(service):
config = make_config()
assert service.calculate_diff(config, "line\n", "line\n") == ""
def test_calculate_diff_format(service):
config = make_config(serial=2, active_serial=1)
diff = service.calculate_diff(config, "new line\n", "old line\n")
assert "*** 1" in diff
assert "--- 2" in diff
assert "! old line" in diff
assert "! new line" in diff
# --- write_config ---
def test_write_config_creates_file_with_config_contents(service, tmp_path):
config = make_config()
output_path = str(tmp_path / "dhcpd.conf")
service.write_config(config, output_path)
with open(output_path) as f:
assert f.read() == config.dhcpd_configuration.return_value
def test_write_config_updates_active_serial(service, tmp_path):
config = make_config(serial=5, active_serial=3)
output_path = str(tmp_path / "dhcpd.conf")
service.write_config(config, output_path)
assert config.active_serial == 5
config.save.assert_called_once()
def test_write_config_returns_old_serial(service, tmp_path):
config = make_config(serial=2, active_serial=1)
output_path = str(tmp_path / "dhcpd.conf")
old_serial, diff = service.write_config(config, output_path)
assert old_serial == 1
def test_write_config_diff_reflects_changes(service, tmp_path):
output_path = str(tmp_path / "dhcpd.conf")
with open(output_path, "w") as f:
f.write("old content\n")
config = make_config(serial=2, active_serial=1)
config.dhcpd_configuration.return_value = "new content\n"
old_serial, diff = service.write_config(config, output_path)
assert "old content" in diff
assert "new content" in diff
# --- restart_service ---
def test_restart_service_returns_true_on_success(service):
with patch("mdb.services.dhcp_sync.getstatusoutput", return_value=(0, "")):
success, output = service.restart_service()
assert success is True
assert output == ""
def test_restart_service_returns_false_on_failure(service):
with patch(
"mdb.services.dhcp_sync.getstatusoutput", return_value=(1, "Job failed")
):
success, output = service.restart_service()
assert success is False
assert output == "Job failed"
+135
View File
@@ -0,0 +1,135 @@
from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
from mdb.management.commands.dhcp_sync import Command as CommandClass
@pytest.fixture
def cmd():
c = CommandClass()
c.stdout = StringIO()
c.stderr = StringIO()
return c
def make_service(serial=2, active_serial=1, restart_success=True, restart_output=""):
svc = MagicMock()
svc.force = False
svc.restart_command = "/usr/sbin/service isc-dhcp-server restart"
config = MagicMock()
config.serial = serial
config.active_serial = active_serial
config.dhcpd_configuration.return_value = "# dhcpd config\n"
svc.get_config.return_value = config
svc.needs_update.return_value = serial != active_serial
svc.write_config.return_value = (active_serial, "- old\n+ new")
svc.restart_service.return_value = (restart_success, restart_output)
return svc, config
# --- handle: nothing to do ---
def test_handle_prints_nothing_to_do_when_no_update_needed(cmd):
svc, config = make_service(serial=1, active_serial=1)
svc.needs_update.return_value = False
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=None, force=False, restart=False)
assert "Nothing to do" in cmd.stdout.getvalue()
# --- handle: stdout mode ---
def test_handle_prints_config_to_stdout_when_no_output_path(cmd):
svc, config = make_service()
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=None, force=False, restart=False)
assert "# dhcpd config" in cmd.stdout.getvalue()
assert "Not writing to file" in cmd.stdout.getvalue()
def test_handle_does_not_write_file_when_no_output_path(cmd):
svc, config = make_service()
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=None, force=False, restart=False)
svc.write_config.assert_not_called()
# --- handle: file mode ---
def test_handle_calls_write_config_when_output_given(cmd, tmp_path):
svc, config = make_service()
output_path = str(tmp_path / "dhcpd.conf")
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=output_path, force=False, restart=False)
svc.write_config.assert_called_once_with(config, output_path)
def test_handle_does_not_restart_when_restart_false(cmd, tmp_path):
svc, config = make_service()
output_path = str(tmp_path / "dhcpd.conf")
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
with patch.object(cmd, "restart_and_notify") as mock_restart:
cmd.handle(output=output_path, force=False, restart=False)
mock_restart.assert_not_called()
def test_handle_calls_restart_and_notify_when_restart_true(cmd, tmp_path):
svc, config = make_service()
output_path = str(tmp_path / "dhcpd.conf")
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
with patch.object(cmd, "restart_and_notify") as mock_restart:
cmd.handle(output=output_path, force=False, restart=True)
mock_restart.assert_called_once()
# --- restart_and_notify ---
def test_restart_and_notify_success_writes_ok(cmd):
svc, config = make_service(restart_success=True)
with patch("mdb.management.commands.dhcp_sync.discord"):
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
assert "ok" in cmd.stdout.getvalue()
def test_restart_and_notify_success_calls_discord(cmd):
svc, config = make_service(restart_success=True)
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
mock_discord.notify.assert_called_once()
assert "✅" in mock_discord.notify.call_args.kwargs["subject"]
def test_restart_and_notify_success_passes_diff_as_preformatted(cmd):
svc, config = make_service(restart_success=True)
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
assert mock_discord.notify.call_args.kwargs["preformatted"] == "- old\n+ new"
def test_restart_and_notify_failure_writes_fail_and_exits(cmd):
svc, config = make_service(restart_success=False, restart_output="Job failed")
with patch("mdb.management.commands.dhcp_sync.discord"):
with patch("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
@@ -0,0 +1,82 @@
from unittest.mock import MagicMock, patch
import pytest
from mdb.services import discord
@pytest.fixture(autouse=True)
def webhook_url(settings):
settings.DISCORD_NOTIFICATION_WEBHOOK = "https://discord.example/webhook"
def test_notify_does_nothing_without_webhook(settings):
settings.DISCORD_NOTIFICATION_WEBHOOK = None
with patch("mdb.services.discord._send_json") as mock:
discord.notify("Subject", "message")
mock.assert_not_called()
def test_notify_sends_json_for_short_content():
with patch("mdb.services.discord._send_json") as mock:
discord.notify("Subject", "message")
mock.assert_called_once()
_, content = mock.call_args.args
assert "**Subject**" in content
assert "message" in content
def test_notify_includes_preformatted_as_diff_code_block():
with patch("mdb.services.discord._send_json") as mock:
discord.notify("Subject", "message", preformatted="- old\n+ new")
_, content = mock.call_args.args
assert "```diff\n- old\n+ new\n```" in content
def test_notify_sends_file_when_content_exceeds_limit():
long_diff = "x" * discord.MAX_CONTENT_LENGTH
with patch("mdb.services.discord._send_as_file") as mock:
discord.notify("Subject", "message", preformatted=long_diff)
mock.assert_called_once()
def test_notify_sends_json_when_content_fits():
with patch("mdb.services.discord._send_json") as mock:
discord.notify("Subject", "short message")
mock.assert_called_once()
def test_send_json_posts_with_mdb_username():
mock_resp = MagicMock()
with patch("requests.post", return_value=mock_resp) as mock_post:
discord._send_json("https://discord.example/webhook", "hello")
payload = mock_post.call_args.kwargs["json"]
assert payload["username"] == "MDB"
assert payload["content"] == "hello"
mock_resp.raise_for_status.assert_called_once()
def test_send_as_file_posts_multipart_with_attachment():
mock_resp = MagicMock()
with patch("requests.post", return_value=mock_resp) as mock_post:
discord._send_as_file(
"https://discord.example/webhook", "Subject", "full content"
)
assert mock_post.call_args.kwargs.get("files") or "files" in str(
mock_post.call_args
)
mock_resp.raise_for_status.assert_called_once()
def test_send_as_file_content_mentions_attachment():
import json
mock_resp = MagicMock()
with patch("requests.post", return_value=mock_resp) as mock_post:
discord._send_as_file(
"https://discord.example/webhook", "MySubject", "full content"
)
data = mock_post.call_args.kwargs["data"]
payload = json.loads(data["payload_json"])
assert "MySubject" in payload["content"]
assert "attachment" in payload["content"]
+37
View File
@@ -0,0 +1,37 @@
import pytest
from mdb.models import Ip4Subnet, Ip6Subnet
@pytest.mark.parametrize(
"network,netmask,expected",
[
# /24 and shorter -> standard octet-boundary name
("203.0.113.0", "255.255.255.0", "113.0.203.in-addr.arpa"),
("10.0.3.0", "255.255.255.0", "3.0.10.in-addr.arpa"),
("10.0.0.0", "255.255.0.0", "0.0.10.in-addr.arpa"),
# longer than /24 -> RFC 2317 classless name
("192.0.2.192", "255.255.255.192", "192-26.2.0.192.in-addr.arpa"),
("10.0.0.0", "255.255.255.252", "0-30.0.0.10.in-addr.arpa"),
("10.1.2.0", "255.255.255.128", "0-25.2.1.10.in-addr.arpa"),
# host bits set -> normalized down to the network address
("192.0.2.200", "255.255.255.192", "192-26.2.0.192.in-addr.arpa"),
],
)
def test_ip4_reverse_zone_name(network, netmask, expected):
subnet = Ip4Subnet(network=network, netmask=netmask)
assert subnet.reverse_zone_name == expected
@pytest.mark.parametrize(
"network,netmask,expected",
[
("2001:db8", 64, "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"),
("2001:db8", 48, "0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"),
("2001:db8:abcd", 56, "0.0.d.c.b.a.8.b.d.0.1.0.0.2.ip6.arpa"),
("2001:db8", 32, "8.b.d.0.1.0.0.2.ip6.arpa"),
],
)
def test_ip6_reverse_zone_name(network, netmask, expected):
subnet = Ip6Subnet(network=network, netmask=netmask)
assert subnet.reverse_zone_name == expected
+45
View File
@@ -0,0 +1,45 @@
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
@@ -0,0 +1,57 @@
"""How mdbsite.settings loads production's settings, in a fresh interpreter each time,
since the settings module is imported once per process."""
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PRINT = (
"import django, os; os.environ['DJANGO_SETTINGS_MODULE'] = 'mdbsite.settings'; "
"django.setup(); from django.conf import settings as s; print(s.ALLOWED_HOSTS, s.DEBUG)"
)
def run(env):
return subprocess.run(
[sys.executable, "-c", PRINT],
cwd=ROOT,
env={
k: v
for k, v in os.environ.items()
if k not in ("MDB_SETTINGS", "DJANGO_DEBUG")
}
| env,
capture_output=True,
text=True,
)
def test_settings_file_overrides(tmp_path):
f = tmp_path / "settings.py"
f.write_text("ALLOWED_HOSTS = ['mdb.example.org']\n")
result = run({"MDB_SETTINGS": str(f)})
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "['mdb.example.org'] False"
def test_failing_settings_file_stops_mdb(tmp_path):
# As on sega after the 24.04 upgrade: python-ldap would not load, and the old
# `except ImportError: pass` left MDB running on the defaults.
f = tmp_path / "settings.py"
f.write_text("import a_module_that_does_not_exist\n")
result = run({"MDB_SETTINGS": str(f)})
assert result.returncode != 0
assert "a_module_that_does_not_exist" in result.stderr
def test_missing_settings_file_stops_mdb(tmp_path):
result = run({"MDB_SETTINGS": str(tmp_path / "missing.py")})
assert result.returncode != 0
def test_debug_is_off_unless_asked():
assert run({}).stdout.strip().endswith("False")
assert run({"DJANGO_DEBUG": "1"}).stdout.strip().endswith("True")
+67
View File
@@ -0,0 +1,67 @@
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
@@ -0,0 +1,322 @@
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
@@ -0,0 +1,210 @@
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
@@ -0,0 +1,417 @@
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" },
]