`, and ``.
-@font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
-@font-family-base: @font-family-serif;
-
-@font-size-base: 18px;
-@font-size-large: ceil((@font-size-base * 1.25)); // ~18px
-@font-size-small: ceil((@font-size-base * 0.85)); // ~12px
-
-@font-size-h1: floor((@font-size-base * 2.6)); // ~36px
-@font-size-h2: floor((@font-size-base * 2.15)); // ~30px
-@font-size-h3: ceil((@font-size-base * 1.7)); // ~24px
-@font-size-h4: ceil((@font-size-base * 1.25)); // ~18px
-@font-size-h5: @font-size-base;
-@font-size-h6: ceil((@font-size-base * 0.85)); // ~12px
-
-//** Unit-less `line-height` for use in components like buttons.
-@line-height-base: 1.428571429; // 20/14
-//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
-@line-height-computed: floor((@font-size-base * @line-height-base)); // ~20px
-
-//** By default, this inherits from the ``.
-@headings-font-family: inherit;
-@headings-font-weight: 500;
-@headings-line-height: 1.1;
-@headings-color: inherit;
-
-
-//== Iconography
-//
-//## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
-
-//** Load fonts from this directory.
-@icon-font-path: "../fonts/";
-//** File name for all font files.
-@icon-font-name: "glyphicons-halflings-regular";
-//** Element ID within SVG icon file.
-@icon-font-svg-id: "glyphicons_halflingsregular";
-
-
-//== Components
-//
-//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
-
-@padding-base-vertical: 6px;
-@padding-base-horizontal: 12px;
-
-@padding-large-vertical: 10px;
-@padding-large-horizontal: 16px;
-
-@padding-small-vertical: 5px;
-@padding-small-horizontal: 10px;
-
-@padding-xs-vertical: 1px;
-@padding-xs-horizontal: 5px;
-
-@line-height-large: 1.3333333; // extra decimals for Win 8.1 Chrome
-@line-height-small: 1.5;
-
-@border-radius-base: 4px;
-@border-radius-large: 6px;
-@border-radius-small: 3px;
-
-//** Global color for active items (e.g., navs or dropdowns).
-@component-active-color: #fff;
-//** Global background color for active items (e.g., navs or dropdowns).
-@component-active-bg: @brand-primary;
-
-//** Width of the `border` for generating carets that indicator dropdowns.
-@caret-width-base: 4px;
-//** Carets increase slightly in size for larger components.
-@caret-width-large: 5px;
-
-
-//== Tables
-//
-//## Customizes the `.table` component with basic values, each used across all table variations.
-
-//** Padding for ``s and ` `s.
-@table-cell-padding: 8px;
-//** Padding for cells in `.table-condensed`.
-@table-condensed-cell-padding: 5px;
-
-//** Default background color used for all tables.
-@table-bg: transparent;
-//** Background color used for `.table-striped`.
-@table-bg-accent: #f9f9f9;
-//** Background color used for `.table-hover`.
-@table-bg-hover: #f5f5f5;
-@table-bg-active: @table-bg-hover;
-
-//** Border color for table and cell borders.
-@table-border-color: #ddd;
-
-
-//== Buttons
-//
-//## For each of Bootstrap's buttons, define text, background and border color.
-
-@btn-font-weight: normal;
-
-@btn-default-color: #333;
-@btn-default-bg: #fff;
-@btn-default-border: #ccc;
-
-@btn-primary-color: #fff;
-@btn-primary-bg: @brand-primary;
-@btn-primary-border: darken(@btn-primary-bg, 5%);
-
-@btn-success-color: #fff;
-@btn-success-bg: @brand-success;
-@btn-success-border: darken(@btn-success-bg, 5%);
-
-@btn-info-color: #fff;
-@btn-info-bg: @brand-info;
-@btn-info-border: darken(@btn-info-bg, 5%);
-
-@btn-warning-color: #fff;
-@btn-warning-bg: @brand-warning;
-@btn-warning-border: darken(@btn-warning-bg, 5%);
-
-@btn-danger-color: #fff;
-@btn-danger-bg: @brand-danger;
-@btn-danger-border: darken(@btn-danger-bg, 5%);
-
-@btn-link-disabled-color: @gray-light;
-
-// Allows for customizing button radius independently from global border radius
-@btn-border-radius-base: @border-radius-base;
-@btn-border-radius-large: @border-radius-large;
-@btn-border-radius-small: @border-radius-small;
-
-
-//== Forms
-//
-//##
-
-//** ` ` background color
-@input-bg: #fff;
-//** ` ` background color
-@input-bg-disabled: @gray-lighter;
-
-//** Text color for ` `s
-@input-color: @gray;
-//** ` ` border color
-@input-border: #ccc;
-
-// TODO: Rename `@input-border-radius` to `@input-border-radius-base` in v4
-//** Default `.form-control` border radius
-// This has no effect on ``s in some browsers, due to the limited stylability of ``s in CSS.
-@input-border-radius: @border-radius-base;
-//** Large `.form-control` border radius
-@input-border-radius-large: @border-radius-large;
-//** Small `.form-control` border radius
-@input-border-radius-small: @border-radius-small;
-
-//** Border color for inputs on focus
-@input-border-focus: #66afe9;
-
-//** Placeholder text color
-@input-color-placeholder: #999;
-
-//** Default `.form-control` height
-@input-height-base: (@line-height-computed + (@padding-base-vertical * 2) + 2);
-//** Large `.form-control` height
-@input-height-large: (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);
-//** Small `.form-control` height
-@input-height-small: (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
-
-//** `.form-group` margin
-@form-group-margin-bottom: 15px;
-
-@legend-color: @gray-dark;
-@legend-border-color: #e5e5e5;
-
-//** Background color for textual input addons
-@input-group-addon-bg: @gray-lighter;
-//** Border color for textual input addons
-@input-group-addon-border-color: @input-border;
-
-//** Disabled cursor for form controls and buttons.
-@cursor-disabled: not-allowed;
-
-
-//== Dropdowns
-//
-//## Dropdown menu container and contents.
-
-//** Background for the dropdown menu.
-@dropdown-bg: #fff;
-//** Dropdown menu `border-color`.
-@dropdown-border: rgba(0,0,0,.15);
-//** Dropdown menu `border-color` **for IE8**.
-@dropdown-fallback-border: #ccc;
-//** Divider color for between dropdown items.
-@dropdown-divider-bg: #e5e5e5;
-
-//** Dropdown link text color.
-@dropdown-link-color: @gray-dark;
-//** Hover color for dropdown links.
-@dropdown-link-hover-color: darken(@gray-dark, 5%);
-//** Hover background for dropdown links.
-@dropdown-link-hover-bg: #f5f5f5;
-
-//** Active dropdown menu item text color.
-@dropdown-link-active-color: @component-active-color;
-//** Active dropdown menu item background color.
-@dropdown-link-active-bg: @component-active-bg;
-
-//** Disabled dropdown menu item background color.
-@dropdown-link-disabled-color: @gray-light;
-
-//** Text color for headers within dropdown menus.
-@dropdown-header-color: @gray-light;
-
-//** Deprecated `@dropdown-caret-color` as of v3.1.0
-@dropdown-caret-color: #000;
-
-
-//-- Z-index master list
-//
-// Warning: Avoid customizing these values. They're used for a bird's eye view
-// of components dependent on the z-axis and are designed to all work together.
-//
-// Note: These variables are not generated into the Customizer.
-
-@zindex-navbar: 1000;
-@zindex-dropdown: 1000;
-@zindex-popover: 1060;
-@zindex-tooltip: 1070;
-@zindex-navbar-fixed: 1030;
-@zindex-modal-background: 1040;
-@zindex-modal: 1050;
-
-
-//== Media queries breakpoints
-//
-//## Define the breakpoints at which your layout will change, adapting to different screen sizes.
-
-// Extra small screen / phone
-//** Deprecated `@screen-xs` as of v3.0.1
-@screen-xs: 480px;
-//** Deprecated `@screen-xs-min` as of v3.2.0
-@screen-xs-min: @screen-xs;
-//** Deprecated `@screen-phone` as of v3.0.1
-@screen-phone: @screen-xs-min;
-
-// Small screen / tablet
-//** Deprecated `@screen-sm` as of v3.0.1
-@screen-sm: 768px;
-@screen-sm-min: @screen-sm;
-//** Deprecated `@screen-tablet` as of v3.0.1
-@screen-tablet: @screen-sm-min;
-
-// Medium screen / desktop
-//** Deprecated `@screen-md` as of v3.0.1
-@screen-md: 992px;
-@screen-md-min: @screen-md;
-//** Deprecated `@screen-desktop` as of v3.0.1
-@screen-desktop: @screen-md-min;
-
-// Large screen / wide desktop
-//** Deprecated `@screen-lg` as of v3.0.1
-@screen-lg: 1200px;
-@screen-lg-min: @screen-lg;
-//** Deprecated `@screen-lg-desktop` as of v3.0.1
-@screen-lg-desktop: @screen-lg-min;
-
-// So media queries don't overlap when required, provide a maximum
-@screen-xs-max: (@screen-sm-min - 1);
-@screen-sm-max: (@screen-md-min - 1);
-@screen-md-max: (@screen-lg-min - 1);
-
-
-//== Grid system
-//
-//## Define your custom responsive grid.
-
-//** Number of columns in the grid.
-@grid-columns: 12;
-//** Padding between columns. Gets divided in half for the left and right.
-@grid-gutter-width: 30px;
-// Navbar collapse
-//** Point at which the navbar becomes uncollapsed.
-@grid-float-breakpoint: @screen-sm-min;
-//** Point at which the navbar begins collapsing.
-@grid-float-breakpoint-max: (@grid-float-breakpoint - 1);
-
-
-//== Container sizes
-//
-//## Define the maximum width of `.container` for different screen sizes.
-
-// Small screen / tablet
-@container-tablet: (720px + @grid-gutter-width);
-//** For `@screen-sm-min` and up.
-@container-sm: @container-tablet;
-
-// Medium screen / desktop
-@container-desktop: (940px + @grid-gutter-width);
-//** For `@screen-md-min` and up.
-@container-md: @container-desktop;
-
-// Large screen / wide desktop
-@container-large-desktop: (1140px + @grid-gutter-width);
-//** For `@screen-lg-min` and up.
-@container-lg: @container-large-desktop;
-
-
-//== Navbar
-//
-//##
-
-// Basics of a navbar
-@navbar-height: 40px;
-@navbar-margin-bottom: @line-height-computed;
-@navbar-border-radius: @border-radius-base;
-@navbar-padding-horizontal: floor((@grid-gutter-width / 2));
-@navbar-padding-vertical: ((@navbar-height - @line-height-computed) / 2);
-@navbar-collapse-max-height: 340px;
-
-@navbar-default-color: @text-color;
-@navbar-default-bg: @cl__white;
-@navbar-default-border: darken(@navbar-default-bg, 6.5%);
-
-// Navbar links
-@navbar-default-link-color: @text-color;
-@navbar-default-link-hover-color: @cl__blue;
-@navbar-default-link-hover-bg: transparent;
-@navbar-default-link-active-color: @cl__blue;
-@navbar-default-link-active-bg: lighten(@cl__black, 97%);
-@navbar-default-link-disabled-color: #ccc;
-@navbar-default-link-disabled-bg: transparent;
-
-// Navbar brand label
-@navbar-default-brand-color: @cl__gray;
-@navbar-default-brand-hover-color: darken(@navbar-default-brand-color, 10%);
-@navbar-default-brand-hover-bg: transparent;
-
-// Navbar toggle
-@navbar-default-toggle-hover-bg: #ddd;
-@navbar-default-toggle-icon-bar-bg: #888;
-@navbar-default-toggle-border-color: #ddd;
-
-
-//=== Inverted navbar
-// Reset inverted navbar basics
-@navbar-inverse-color: lighten(@gray-light, 15%);
-@navbar-inverse-bg: #222;
-@navbar-inverse-border: darken(@navbar-inverse-bg, 10%);
-
-// Inverted navbar links
-@navbar-inverse-link-color: lighten(@gray-light, 15%);
-@navbar-inverse-link-hover-color: #fff;
-@navbar-inverse-link-hover-bg: transparent;
-@navbar-inverse-link-active-color: @navbar-inverse-link-hover-color;
-@navbar-inverse-link-active-bg: darken(@navbar-inverse-bg, 10%);
-@navbar-inverse-link-disabled-color: #444;
-@navbar-inverse-link-disabled-bg: transparent;
-
-// Inverted navbar brand label
-@navbar-inverse-brand-color: @navbar-inverse-link-color;
-@navbar-inverse-brand-hover-color: #fff;
-@navbar-inverse-brand-hover-bg: transparent;
-
-// Inverted navbar toggle
-@navbar-inverse-toggle-hover-bg: #333;
-@navbar-inverse-toggle-icon-bar-bg: #fff;
-@navbar-inverse-toggle-border-color: #333;
-
-
-//== Navs
-//
-//##
-
-//=== Shared nav styles
-@nav-link-padding: 5px 10px;
-@nav-link-hover-bg: @gray-lighter;
-
-@nav-disabled-link-color: @gray-light;
-@nav-disabled-link-hover-color: @gray-light;
-
-//== Tabs
-@nav-tabs-border-color: #ddd;
-
-@nav-tabs-link-hover-border-color: @gray-lighter;
-
-@nav-tabs-active-link-hover-bg: @body-bg;
-@nav-tabs-active-link-hover-color: @gray;
-@nav-tabs-active-link-hover-border-color: #ddd;
-
-@nav-tabs-justified-link-border-color: #ddd;
-@nav-tabs-justified-active-link-border-color: @body-bg;
-
-//== Pills
-@nav-pills-border-radius: @border-radius-base;
-@nav-pills-active-link-hover-bg: @component-active-bg;
-@nav-pills-active-link-hover-color: @component-active-color;
-
-
-//== Pagination
-//
-//##
-
-@pagination-color: @link-color;
-@pagination-bg: #fff;
-@pagination-border: #ddd;
-
-@pagination-hover-color: @link-hover-color;
-@pagination-hover-bg: @gray-lighter;
-@pagination-hover-border: #ddd;
-
-@pagination-active-color: #fff;
-@pagination-active-bg: @brand-primary;
-@pagination-active-border: @brand-primary;
-
-@pagination-disabled-color: @gray-light;
-@pagination-disabled-bg: #fff;
-@pagination-disabled-border: #ddd;
-
-
-//== Pager
-//
-//##
-
-@pager-bg: @pagination-bg;
-@pager-border: @pagination-border;
-@pager-border-radius: 15px;
-
-@pager-hover-bg: @pagination-hover-bg;
-
-@pager-active-bg: @pagination-active-bg;
-@pager-active-color: @pagination-active-color;
-
-@pager-disabled-color: @pagination-disabled-color;
-
-
-//== Jumbotron
-//
-//##
-
-@jumbotron-padding: 30px;
-@jumbotron-color: inherit;
-@jumbotron-bg: @cl__yellow;
-@jumbotron-heading-color: inherit;
-@jumbotron-font-size: ceil((@font-size-base * 1.5));
-@jumbotron-heading-font-size: ceil((@font-size-base * 4.5));
-
-
-//== Form states and alerts
-//
-//## Define colors for form feedback states and, by default, alerts.
-
-@state-success-text: darken(@cl__green, 10%);
-@state-success-bg: lighten(@cl__green, 40%);
-@state-success-border: darken(spin(@state-success-bg, -10), 5%);
-
-@state-info-text: @cl__blue;
-@state-info-bg: lighten(@cl__blue, 40%);
-@state-info-border: darken(spin(@state-info-bg, -10), 7%);
-
-@state-warning-text: #c09853;
-@state-warning-bg: #fcf8e3;
-@state-warning-border: darken(spin(@state-warning-bg, -10), 5%);
-
-@state-danger-text: #b94a48;
-@state-danger-bg: #f2dede;
-@state-danger-border: darken(spin(@state-danger-bg, -10), 5%);
-
-
-//== Tooltips
-//
-//##
-
-//** Tooltip max width
-@tooltip-max-width: 200px;
-//** Tooltip text color
-@tooltip-color: #fff;
-//** Tooltip background color
-@tooltip-bg: #000;
-@tooltip-opacity: .9;
-
-//** Tooltip arrow width
-@tooltip-arrow-width: 5px;
-//** Tooltip arrow color
-@tooltip-arrow-color: @tooltip-bg;
-
-
-//== Popovers
-//
-//##
-
-//** Popover body background color
-@popover-bg: #fff;
-//** Popover maximum width
-@popover-max-width: 276px;
-//** Popover border color
-@popover-border-color: rgba(0,0,0,.2);
-//** Popover fallback border color
-@popover-fallback-border-color: #ccc;
-
-//** Popover title background color
-@popover-title-bg: darken(@popover-bg, 3%);
-
-//** Popover arrow width
-@popover-arrow-width: 10px;
-//** Popover arrow color
-@popover-arrow-color: @popover-bg;
-
-//** Popover outer arrow width
-@popover-arrow-outer-width: (@popover-arrow-width + 1);
-//** Popover outer arrow color
-@popover-arrow-outer-color: fadein(@popover-border-color, 5%);
-//** Popover outer arrow fallback color
-@popover-arrow-outer-fallback-color: darken(@popover-fallback-border-color, 20%);
-
-
-//== Labels
-//
-//##
-
-//** Default label background color
-@label-default-bg: @gray-light;
-//** Primary label background color
-@label-primary-bg: @brand-primary;
-//** Success label background color
-@label-success-bg: @brand-success;
-//** Info label background color
-@label-info-bg: @brand-info;
-//** Warning label background color
-@label-warning-bg: @brand-warning;
-//** Danger label background color
-@label-danger-bg: @brand-danger;
-
-//** Default label text color
-@label-color: #fff;
-//** Default text color of a linked label
-@label-link-hover-color: #fff;
-
-
-//== Modals
-//
-//##
-
-//** Padding applied to the modal body
-@modal-inner-padding: 15px;
-
-//** Padding applied to the modal title
-@modal-title-padding: 15px;
-//** Modal title line-height
-@modal-title-line-height: @line-height-base;
-
-//** Background color of modal content area
-@modal-content-bg: #fff;
-//** Modal content border color
-@modal-content-border-color: rgba(0,0,0,.2);
-//** Modal content border color **for IE8**
-@modal-content-fallback-border-color: #999;
-
-//** Modal backdrop background color
-@modal-backdrop-bg: #000;
-//** Modal backdrop opacity
-@modal-backdrop-opacity: .5;
-//** Modal header border color
-@modal-header-border-color: #e5e5e5;
-//** Modal footer border color
-@modal-footer-border-color: @modal-header-border-color;
-
-@modal-lg: 900px;
-@modal-md: 600px;
-@modal-sm: 300px;
-
-
-//== Alerts
-//
-//## Define alert colors, border radius, and padding.
-
-@alert-padding: 15px;
-@alert-border-radius: @border-radius-base;
-@alert-link-font-weight: bold;
-
-@alert-success-bg: @state-success-bg;
-@alert-success-text: @state-success-text;
-@alert-success-border: @state-success-border;
-
-@alert-info-bg: @state-info-bg;
-@alert-info-text: @state-info-text;
-@alert-info-border: @state-info-border;
-
-@alert-warning-bg: @state-warning-bg;
-@alert-warning-text: @state-warning-text;
-@alert-warning-border: @state-warning-border;
-
-@alert-danger-bg: @state-danger-bg;
-@alert-danger-text: @state-danger-text;
-@alert-danger-border: @state-danger-border;
-
-
-//== Progress bars
-//
-//##
-
-//** Background color of the whole progress component
-@progress-bg: #f5f5f5;
-//** Progress bar text color
-@progress-bar-color: #fff;
-//** Variable for setting rounded corners on progress bar.
-@progress-border-radius: @border-radius-base;
-
-//** Default progress bar color
-@progress-bar-bg: @brand-primary;
-//** Success progress bar color
-@progress-bar-success-bg: @brand-success;
-//** Warning progress bar color
-@progress-bar-warning-bg: @brand-warning;
-//** Danger progress bar color
-@progress-bar-danger-bg: @brand-danger;
-//** Info progress bar color
-@progress-bar-info-bg: @brand-info;
-
-
-//== List group
-//
-//##
-
-//** Background color on `.list-group-item`
-@list-group-bg: #fff;
-//** `.list-group-item` border color
-@list-group-border: #ddd;
-//** List group border radius
-@list-group-border-radius: @border-radius-base;
-
-//** Background color of single list items on hover
-@list-group-hover-bg: #f5f5f5;
-//** Text color of active list items
-@list-group-active-color: @component-active-color;
-//** Background color of active list items
-@list-group-active-bg: @component-active-bg;
-//** Border color of active list elements
-@list-group-active-border: @list-group-active-bg;
-//** Text color for content within active list items
-@list-group-active-text-color: lighten(@list-group-active-bg, 40%);
-
-//** Text color of disabled list items
-@list-group-disabled-color: @gray-light;
-//** Background color of disabled list items
-@list-group-disabled-bg: @gray-lighter;
-//** Text color for content within disabled list items
-@list-group-disabled-text-color: @list-group-disabled-color;
-
-@list-group-link-color: #555;
-@list-group-link-hover-color: @list-group-link-color;
-@list-group-link-heading-color: #333;
-
-
-//== Panels
-//
-//##
-
-@panel-bg: #fff;
-@panel-body-padding: 15px;
-@panel-heading-padding: 10px 15px;
-@panel-footer-padding: @panel-heading-padding;
-@panel-border-radius: @border-radius-base;
-
-//** Border color for elements within panels
-@panel-inner-border: #ddd;
-@panel-footer-bg: #f5f5f5;
-
-@panel-default-text: lighten(@cl__black, 30%);
-@panel-default-border: #ddd;
-@panel-default-heading-bg: #f5f5f5;
-
-@panel-primary-text: #fff;
-@panel-primary-border: @brand-primary;
-@panel-primary-heading-bg: @brand-primary;
-
-@panel-success-text: @state-success-text;
-@panel-success-border: @state-success-border;
-@panel-success-heading-bg: @state-success-bg;
-
-@panel-info-text: @state-info-text;
-@panel-info-border: @state-info-border;
-@panel-info-heading-bg: @state-info-bg;
-
-@panel-warning-text: @state-warning-text;
-@panel-warning-border: @state-warning-border;
-@panel-warning-heading-bg: @state-warning-bg;
-
-@panel-danger-text: @state-danger-text;
-@panel-danger-border: @state-danger-border;
-@panel-danger-heading-bg: @state-danger-bg;
-
-
-//== Thumbnails
-//
-//##
-
-//** Padding around the thumbnail image
-@thumbnail-padding: 4px;
-//** Thumbnail background color
-@thumbnail-bg: @body-bg;
-//** Thumbnail border color
-@thumbnail-border: #ddd;
-//** Thumbnail border radius
-@thumbnail-border-radius: @border-radius-base;
-
-//** Custom text color for thumbnail captions
-@thumbnail-caption-color: @text-color;
-//** Padding around the thumbnail caption
-@thumbnail-caption-padding: 9px;
-
-
-//== Wells
-//
-//##
-
-@well-bg: lighten(@cl__gray, 30%);
-@well-border: lighten(@cl__blue, 50%);
-
-
-//== Badges
-//
-//##
-
-@badge-color: #fff;
-//** Linked badge text color on hover
-@badge-link-hover-color: #fff;
-@badge-bg: @gray-light;
-
-//** Badge text color in active nav link
-@badge-active-color: @link-color;
-//** Badge background color in active nav link
-@badge-active-bg: #fff;
-
-@badge-font-weight: bold;
-@badge-line-height: 1;
-@badge-border-radius: 10px;
-
-
-//== Breadcrumbs
-//
-//##
-
-@breadcrumb-padding-vertical: 8px;
-@breadcrumb-padding-horizontal: 15px;
-//** Breadcrumb background color
-@breadcrumb-bg: @cl__white;
-//** Breadcrumb text color
-@breadcrumb-color: #ccc;
-//** Text color of current page in the breadcrumb
-@breadcrumb-active-color: @cl__gray;
-//** Textual separator for between breadcrumb elements
-@breadcrumb-separator: "/";
-
-
-//== Carousel
-//
-//##
-
-@carousel-text-shadow: 0 1px 2px rgba(0,0,0,.6);
-
-@carousel-control-color: #fff;
-@carousel-control-width: 15%;
-@carousel-control-opacity: .5;
-@carousel-control-font-size: 20px;
-
-@carousel-indicator-active-bg: #fff;
-@carousel-indicator-border-color: #fff;
-
-@carousel-caption-color: #fff;
-
-
-//== Close
-//
-//##
-
-@close-font-weight: bold;
-@close-color: #000;
-@close-text-shadow: 0 1px 0 #fff;
-
-
-//== Code
-//
-//##
-
-@code-color: @cl__blue;
-@code-bg: lighten(@cl__blue, 50%);
-
-@kbd-color: #fff;
-@kbd-bg: lighten(@cl__blue, 10%);
-
-@pre-bg: #f5f5f5;
-@pre-color: @gray-dark;
-@pre-border-color: #ccc;
-@pre-scrollable-max-height: 340px;
-
-
-//== Type
-//
-//##
-
-//** Horizontal offset for forms and lists.
-@component-offset-horizontal: 180px;
-//** Text muted color
-@text-muted: @gray-light;
-//** Abbreviations and acronyms border color
-@abbr-border-color: @gray-light;
-//** Headings small color
-@headings-small-color: @gray-light;
-//** Blockquote small color
-@blockquote-small-color: @gray-light;
-//** Blockquote font size
-@blockquote-font-size: @font-size-base;
-//** Blockquote border color
-@blockquote-border-color: @gray-lighter;
-//** Page header border color
-@page-header-border-color: @gray-lighter;
-//** Width of horizontal description list titles
-@dl-horizontal-offset: @component-offset-horizontal;
-//** Horizontal line color.
-@hr-border: lighten(@cl__gray, 10%);
diff --git a/data/static_dist/pythonz.js b/data/static_dist/pythonz.js
deleted file mode 100644
index 5311555f..00000000
--- a/data/static_dist/pythonz.js
+++ /dev/null
@@ -1,171 +0,0 @@
-//xross.debug = true;
-
-pythonz = {
-
- bootstrap: function() {
- xross.automate();
- $(function(){
- pythonz.mark_user();
- sitecats.bootstrap();
- sitecats.make_cloud('tags_box');
- $('.mod__has_tooltip').tooltip()
- });
- },
-
- mark_user: function() {
- $('.py_user').each(function(i, el) {
- var html = el.innerHTML.replace(/\[u:(\d+):\s*([^\]\s]+)\s*\]/g, '$2 ');
- $(el).html(html);
- });
- },
-
- Reference: {
-
- RULE_PYVERSION_ADDED: [/\+py([\w\.]+)/g, '$1
'],
- RULE_PYVERSION_REMOVED: [/-py([\w\.]+)/g, '$1
'],
- RULE_LITERAL: [/'([^']+)'/g, '$1 '],
- RULE_UNDERMETHOD: [/(__[^\s]+__)/g, '$1 '],
- RULE_BASE_TYPES: [
- /([^\w/])(bool|callable|dict|False|int|iterable|iterator|list|None|object|set|str|True|tuple|unicode)([^\w])/g,
- '$1$2 $3'
- ],
- RULE_EXCEPTIONS: [
- /([^\w])(AttributeError|ImportError|IndexError|KeyError|NotImplementedError|RuntimeError|StopIteration|SyntaxError|SystemError|TypeError|UnboundLocalError|ValueError)([^\w])/g,
- '$1$2
$3'
- ],
- RULE_EMDASH: [/\s+-\s+/g, ' — '],
-
- decorate_description: function(area_id) {
- this.decorate_area(area_id,
- [
- this.RULE_PYVERSION_REMOVED,
- this.RULE_PYVERSION_ADDED,
- this.RULE_BASE_TYPES,
- this.RULE_EXCEPTIONS,
- this.RULE_EMDASH
- ]
- )
- },
-
- decorate_func_result: function(area_id) {
- this.decorate_area(area_id,
- [
- this.RULE_PYVERSION_REMOVED,
- this.RULE_PYVERSION_ADDED,
- this.RULE_BASE_TYPES,
- this.RULE_EMDASH
- ]
- )
- },
-
- decorate_func_params: function(area_id) {
- var func_process_args = function (match_str, arg_name, separator) {
- arg_name = arg_name.replace(/([^=]+)(=.+)/g, '$1$2 ');
- return ' ' + arg_name + ' ' + separator;
- };
-
- this.decorate_area(area_id,
- [
- [/([^>\s]+)(\s--)/g, func_process_args],
- [/--/g, ':'],
- this.RULE_PYVERSION_REMOVED,
- this.RULE_PYVERSION_ADDED,
- this.RULE_LITERAL,
- this.RULE_UNDERMETHOD,
- this.RULE_BASE_TYPES,
- this.RULE_EXCEPTIONS,
- this.RULE_EMDASH
- ]
- )
- },
-
- decorate_area: function(area_id, rules) {
- var $area = $('#' + area_id),
- html = $area.html();
-
- if (html !== undefined) {
- $.each(rules, function (idx, rule) {
- html = html.replace(rule[0], rule[1]);
- });
- }
-
- $area.html(html);
-
- }
-
- },
-
- Map: function (map_el_id, objects) {
-
- var self = this,
- _map_el = $('#'+map_el_id),
- _map_objs = objects;
-
- this.get_bounds_for_coords = function(coords) {
- return ymaps.util.bounds.getCenterAndZoom(coords, [_map_el.width(), _map_el.height()])
- };
-
- this.get_placemarks_from_map_objects = function(objects) {
- var all_marks = [],
- mark_idx = 0;
-
- if (objects===undefined) {
- objects = _map_objs
- }
-
- $.each(objects, function(id, props) {
- var coords = props.coords,
- title = props.title,
- descr = props.descr,
- link = props.link;
-
- all_marks[mark_idx] = new ymaps.Placemark(coords, {
- balloonContentHeader: title,
- balloonContentBody: descr,
- balloonContentFooter: link,
- clusterCaption: title,
- place_id: id
- }, {
- hideIconOnBalloonOpen: false,
- preset: 'islands#darkBlueCircleDotIcon'
- });
-
- mark_idx++;
- });
- return all_marks
- };
-
- this.get_clusterer = function() {
- var objs = self.get_placemarks_from_map_objects(),
- clusterer = new ymaps.Clusterer({
- preset: 'islands#darkBlueClusterIcons',
- clusterDisableClickZoom: true,
- clusterBalloonPanelMaxMapArea: 0,
- clusterBalloonContentLayoutWidth: 250,
- clusterBalloonContentLayoutHeight: 100,
- clusterBalloonLeftColumnWidth: 100
- });
- clusterer.add(objs);
- return clusterer;
- };
-
- this.init_map = function () {
- ymaps.ready(function () {
- var clusterer = self.get_clusterer(),
- map_state = self.get_bounds_for_coords(clusterer.getBounds());
-
- $.extend(map_state, {
- controls: ['zoomControl']
- });
-
- var map = new ymaps.Map(_map_el.attr('id'), map_state);
- map.geoObjects.add(clusterer);
- })
- };
-
- self.init_map();
- }
-
-};
-
-pythonz.bootstrap();
diff --git a/data/static_src/css/bootstrap.css b/data/static_src/css/bootstrap.css
deleted file mode 100644
index cd36b52a..00000000
--- a/data/static_src/css/bootstrap.css
+++ /dev/null
@@ -1,5 +0,0 @@
-/*!
- * Bootstrap v3.3.5 (http://getbootstrap.com)
- * Copyright 2011-2015 Twitter, Inc.
- * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
- *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,*:before,*:after{background:transparent!important;color:#000!important;box-shadow:none!important;text-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="#"]:after,a[href^="javascript:"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100%!important}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered th,.table-bordered td{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url('../fonts/glyphicons-halflings-regular.eot');src:url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'),url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'),url('../fonts/glyphicons-halflings-regular.woff') format('woff'),url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'),url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:normal;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\2a"}.glyphicon-plus:before{content:"\2b"}.glyphicon-euro:before,.glyphicon-eur:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}*:before,*:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:Georgia,"Times New Roman",Times,serif;font-size:18px;line-height:1.428571429;color:#555;background-color:#fff}input,button,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#3776ab;text-decoration:none}a:hover,a:focus{color:#244e71;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.img-responsive,.thumbnail>img,.thumbnail a>img,.carousel-inner>.item>img,.carousel-inner>.item>a>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{padding:4px;line-height:1.428571429;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out;display:inline-block;max-width:100%;height:auto}.img-circle{border-radius:50%}hr{margin-top:25px;margin-bottom:25px;border:0;border-top:1px solid #c3c3c3}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role="button"]{cursor:pointer}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small,.h1 small,.h2 small,.h3 small,.h4 small,.h5 small,.h6 small,h1 .small,h2 .small,h3 .small,h4 .small,h5 .small,h6 .small,.h1 .small,.h2 .small,.h3 .small,.h4 .small,.h5 .small,.h6 .small{font-weight:normal;line-height:1;color:#777}h1,.h1,h2,.h2,h3,.h3{margin-top:25px;margin-bottom:12.5px}h1 small,.h1 small,h2 small,.h2 small,h3 small,.h3 small,h1 .small,.h1 .small,h2 .small,.h2 .small,h3 .small,.h3 .small{font-size:65%}h4,.h4,h5,.h5,h6,.h6{margin-top:12.5px;margin-bottom:12.5px}h4 small,.h4 small,h5 small,.h5 small,h6 small,.h6 small,h4 .small,.h4 .small,h5 .small,.h5 .small,h6 .small,.h6 .small{font-size:75%}h1,.h1{font-size:46px}h2,.h2{font-size:38px}h3,.h3{font-size:31px}h4,.h4{font-size:23px}h5,.h5{font-size:18px}h6,.h6{font-size:16px}p{margin:0 0 12.5px}.lead{margin-bottom:25px;font-size:20px;font-weight:300;line-height:1.4}@media(min-width:768px){.lead{font-size:27px}}small,.small{font-size:88%}mark,.mark{background-color:#fcf8e3;padding:.2em}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#82b043}a.text-primary:hover,a.text-primary:focus{color:#678b35}.text-success{color:#678b35}a.text-success:hover,a.text-success:focus{color:#4b6627}.text-info{color:#3776ab}a.text-info:hover,a.text-info:focus{color:#2b5b84}.text-warning{color:#c09853}a.text-warning:hover,a.text-warning:focus{color:#a47e3c}.text-danger{color:#b94a48}a.text-danger:hover,a.text-danger:focus{color:#953b39}.bg-primary{color:#fff;background-color:#82b043}a.bg-primary:hover,a.bg-primary:focus{background-color:#678b35}.bg-success{background-color:#e2eed1}a.bg-success:hover,a.bg-success:focus{background-color:#cae0ac}.bg-info{background-color:#c2d9ec}a.bg-info:hover,a.bg-info:focus{background-color:#9cc0df}.bg-warning{background-color:#fcf8e3}a.bg-warning:hover,a.bg-warning:focus{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:hover,a.bg-danger:focus{background-color:#e4b9b9}.page-header{padding-bottom:11.5px;margin:50px 0 25px;border-bottom:1px solid #eee}ul,ol{margin-top:0;margin-bottom:12.5px}ul ul,ol ul,ul ol,ol ol{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none;margin-left:-5px}.list-inline>li{display:inline-block;padding-left:5px;padding-right:5px}dl{margin-top:0;margin-bottom:25px}dt,dd{line-height:1.428571429}dt{font-weight:bold}dd{margin-left:0}@media(min-width:768px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[title],abbr[data-original-title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:12.5px 25px;margin:0 0 25px;font-size:18px;border-left:5px solid #eee}blockquote p:last-child,blockquote ul:last-child,blockquote ol:last-child{margin-bottom:0}blockquote footer,blockquote small,blockquote .small{display:block;font-size:80%;line-height:1.428571429;color:#777}blockquote footer:before,blockquote small:before,blockquote .small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #eee;border-left:0;text-align:right}.blockquote-reverse footer:before,blockquote.pull-right footer:before,.blockquote-reverse small:before,blockquote.pull-right small:before,.blockquote-reverse .small:before,blockquote.pull-right .small:before{content:''}.blockquote-reverse footer:after,blockquote.pull-right footer:after,.blockquote-reverse small:after,blockquote.pull-right small:after,.blockquote-reverse .small:after,blockquote.pull-right .small:after{content:'\00A0 \2014'}address{margin-bottom:25px;font-style:normal;line-height:1.428571429}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#3776ab;background-color:#e9f1f8;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#4f90c6;border-radius:3px;box-shadow:inset 0 -1px 0 rgba(0,0,0,0.25)}kbd kbd{padding:0;font-size:100%;font-weight:bold;box-shadow:none}pre{display:block;padding:12px;margin:0 0 12.5px;font-size:17px;line-height:1.428571429;word-break:break-all;word-wrap:break-word;color:#333;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}@media(min-width:768px){.container{width:750px}}@media(min-width:992px){.container{width:970px}}@media(min-width:1200px){.container{width:1170px}}.container-fluid{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.row{margin-left:-15px;margin-right:-15px}.col-xs-1,.col-sm-1,.col-md-1,.col-lg-1,.col-xs-2,.col-sm-2,.col-md-2,.col-lg-2,.col-xs-3,.col-sm-3,.col-md-3,.col-lg-3,.col-xs-4,.col-sm-4,.col-md-4,.col-lg-4,.col-xs-5,.col-sm-5,.col-md-5,.col-lg-5,.col-xs-6,.col-sm-6,.col-md-6,.col-lg-6,.col-xs-7,.col-sm-7,.col-md-7,.col-lg-7,.col-xs-8,.col-sm-8,.col-md-8,.col-lg-8,.col-xs-9,.col-sm-9,.col-md-9,.col-lg-9,.col-xs-10,.col-sm-10,.col-md-10,.col-lg-10,.col-xs-11,.col-sm-11,.col-md-11,.col-lg-11,.col-xs-12,.col-sm-12,.col-md-12,.col-lg-12{position:relative;min-height:1px;padding-left:15px;padding-right:15px}.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666666666666%}.col-xs-10{width:83.33333333333334%}.col-xs-9{width:75%}.col-xs-8{width:66.66666666666666%}.col-xs-7{width:58.333333333333336%}.col-xs-6{width:50%}.col-xs-5{width:41.66666666666667%}.col-xs-4{width:33.33333333333333%}.col-xs-3{width:25%}.col-xs-2{width:16.666666666666664%}.col-xs-1{width:8.333333333333332%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666666666666%}.col-xs-pull-10{right:83.33333333333334%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666666666666%}.col-xs-pull-7{right:58.333333333333336%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666666666667%}.col-xs-pull-4{right:33.33333333333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.666666666666664%}.col-xs-pull-1{right:8.333333333333332%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666666666666%}.col-xs-push-10{left:83.33333333333334%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666666666666%}.col-xs-push-7{left:58.333333333333336%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666666666667%}.col-xs-push-4{left:33.33333333333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.666666666666664%}.col-xs-push-1{left:8.333333333333332%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666666666666%}.col-xs-offset-10{margin-left:83.33333333333334%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666666666666%}.col-xs-offset-7{margin-left:58.333333333333336%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666666666667%}.col-xs-offset-4{margin-left:33.33333333333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.666666666666664%}.col-xs-offset-1{margin-left:8.333333333333332%}.col-xs-offset-0{margin-left:0}@media(min-width:768px){.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666666666666%}.col-sm-10{width:83.33333333333334%}.col-sm-9{width:75%}.col-sm-8{width:66.66666666666666%}.col-sm-7{width:58.333333333333336%}.col-sm-6{width:50%}.col-sm-5{width:41.66666666666667%}.col-sm-4{width:33.33333333333333%}.col-sm-3{width:25%}.col-sm-2{width:16.666666666666664%}.col-sm-1{width:8.333333333333332%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666666666666%}.col-sm-pull-10{right:83.33333333333334%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666666666666%}.col-sm-pull-7{right:58.333333333333336%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666666666667%}.col-sm-pull-4{right:33.33333333333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.666666666666664%}.col-sm-pull-1{right:8.333333333333332%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666666666666%}.col-sm-push-10{left:83.33333333333334%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666666666666%}.col-sm-push-7{left:58.333333333333336%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666666666667%}.col-sm-push-4{left:33.33333333333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.666666666666664%}.col-sm-push-1{left:8.333333333333332%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666666666666%}.col-sm-offset-10{margin-left:83.33333333333334%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666666666666%}.col-sm-offset-7{margin-left:58.333333333333336%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666666666667%}.col-sm-offset-4{margin-left:33.33333333333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.666666666666664%}.col-sm-offset-1{margin-left:8.333333333333332%}.col-sm-offset-0{margin-left:0}}@media(min-width:992px){.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666666666666%}.col-md-10{width:83.33333333333334%}.col-md-9{width:75%}.col-md-8{width:66.66666666666666%}.col-md-7{width:58.333333333333336%}.col-md-6{width:50%}.col-md-5{width:41.66666666666667%}.col-md-4{width:33.33333333333333%}.col-md-3{width:25%}.col-md-2{width:16.666666666666664%}.col-md-1{width:8.333333333333332%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666666666666%}.col-md-pull-10{right:83.33333333333334%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666666666666%}.col-md-pull-7{right:58.333333333333336%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666666666667%}.col-md-pull-4{right:33.33333333333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.666666666666664%}.col-md-pull-1{right:8.333333333333332%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666666666666%}.col-md-push-10{left:83.33333333333334%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666666666666%}.col-md-push-7{left:58.333333333333336%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666666666667%}.col-md-push-4{left:33.33333333333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.666666666666664%}.col-md-push-1{left:8.333333333333332%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666666666666%}.col-md-offset-10{margin-left:83.33333333333334%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666666666666%}.col-md-offset-7{margin-left:58.333333333333336%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666666666667%}.col-md-offset-4{margin-left:33.33333333333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.666666666666664%}.col-md-offset-1{margin-left:8.333333333333332%}.col-md-offset-0{margin-left:0}}@media(min-width:1200px){.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666666666666%}.col-lg-10{width:83.33333333333334%}.col-lg-9{width:75%}.col-lg-8{width:66.66666666666666%}.col-lg-7{width:58.333333333333336%}.col-lg-6{width:50%}.col-lg-5{width:41.66666666666667%}.col-lg-4{width:33.33333333333333%}.col-lg-3{width:25%}.col-lg-2{width:16.666666666666664%}.col-lg-1{width:8.333333333333332%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666666666666%}.col-lg-pull-10{right:83.33333333333334%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666666666666%}.col-lg-pull-7{right:58.333333333333336%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666666666667%}.col-lg-pull-4{right:33.33333333333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.666666666666664%}.col-lg-pull-1{right:8.333333333333332%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666666666666%}.col-lg-push-10{left:83.33333333333334%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666666666666%}.col-lg-push-7{left:58.333333333333336%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666666666667%}.col-lg-push-4{left:33.33333333333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.666666666666664%}.col-lg-push-1{left:8.333333333333332%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666666666666%}.col-lg-offset-10{margin-left:83.33333333333334%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666666666666%}.col-lg-offset-7{margin-left:58.333333333333336%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666666666667%}.col-lg-offset-4{margin-left:33.33333333333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.666666666666664%}.col-lg-offset-1{margin-left:8.333333333333332%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:25px}.table>thead>tr>th,.table>tbody>tr>th,.table>tfoot>tr>th,.table>thead>tr>td,.table>tbody>tr>td,.table>tfoot>tr>td{padding:8px;line-height:1.428571429;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>th,.table>caption+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>td,.table>thead:first-child>tr:first-child>td{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>thead>tr>th,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>tbody>tr>td,.table-condensed>tfoot>tr>td{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>td{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*="col-"]{position:static;float:none;display:table-column}table td[class*="col-"],table th[class*="col-"]{position:static;float:none;display:table-cell}.table>thead>tr>td.active,.table>tbody>tr>td.active,.table>tfoot>tr>td.active,.table>thead>tr>th.active,.table>tbody>tr>th.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>tbody>tr.active>td,.table>tfoot>tr.active>td,.table>thead>tr.active>th,.table>tbody>tr.active>th,.table>tfoot>tr.active>th{background-color:#f5f5f5}.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover,.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr.active:hover>th{background-color:#e8e8e8}.table>thead>tr>td.success,.table>tbody>tr>td.success,.table>tfoot>tr>td.success,.table>thead>tr>th.success,.table>tbody>tr>th.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>tbody>tr.success>td,.table>tfoot>tr.success>td,.table>thead>tr.success>th,.table>tbody>tr.success>th,.table>tfoot>tr.success>th{background-color:#e2eed1}.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover,.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr.success:hover>th{background-color:#d6e7bf}.table>thead>tr>td.info,.table>tbody>tr>td.info,.table>tfoot>tr>td.info,.table>thead>tr>th.info,.table>tbody>tr>th.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>tbody>tr.info>td,.table>tfoot>tr.info>td,.table>thead>tr.info>th,.table>tbody>tr.info>th,.table>tfoot>tr.info>th{background-color:#c2d9ec}.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover,.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr.info:hover>th{background-color:#afcde5}.table>thead>tr>td.warning,.table>tbody>tr>td.warning,.table>tfoot>tr>td.warning,.table>thead>tr>th.warning,.table>tbody>tr>th.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>tbody>tr.warning>td,.table>tfoot>tr.warning>td,.table>thead>tr.warning>th,.table>tbody>tr.warning>th,.table>tfoot>tr.warning>th{background-color:#fcf8e3}.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover,.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr.warning:hover>th{background-color:#faf2cc}.table>thead>tr>td.danger,.table>tbody>tr>td.danger,.table>tfoot>tr>td.danger,.table>thead>tr>th.danger,.table>tbody>tr>th.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>tbody>tr.danger>td,.table>tfoot>tr.danger>td,.table>thead>tr.danger>th,.table>tbody>tr.danger>th,.table>tfoot>tr.danger>th{background-color:#f2dede}.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover,.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr.danger:hover>th{background-color:#ebcccc}.table-responsive{overflow-x:auto;min-height:.01%}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:18.75px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>thead>tr>th,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tfoot>tr>td{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>thead>tr>th:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.table-responsive>.table-bordered>thead>tr>th:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>th,.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}}fieldset{padding:0;margin:0;border:0;min-width:0}legend{display:block;width:100%;padding:0;margin-bottom:25px;font-size:27px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:bold}input[type="search"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type="radio"],input[type="checkbox"]{margin:4px 0 0;margin-top:1px \9;line-height:normal}input[type="file"]{display:block}input[type="range"]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type="file"]:focus,input[type="radio"]:focus,input[type="checkbox"]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:18px;line-height:1.428571429;color:#555}.form-control{display:block;width:100%;height:39px;padding:6px 12px;font-size:18px;line-height:1.428571429;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-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-color ease-in-out .15s,box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,0.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type="search"]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type="date"].form-control,input[type="time"].form-control,input[type="datetime-local"].form-control,input[type="month"].form-control{line-height:39px}input[type="date"].input-sm,input[type="time"].input-sm,input[type="datetime-local"].input-sm,input[type="month"].input-sm,.input-group-sm input[type="date"],.input-group-sm input[type="time"],.input-group-sm input[type="datetime-local"],.input-group-sm input[type="month"]{line-height:36px}input[type="date"].input-lg,input[type="time"].input-lg,input[type="datetime-local"].input-lg,input[type="month"].input-lg,.input-group-lg input[type="date"],.input-group-lg input[type="time"],.input-group-lg input[type="datetime-local"],.input-group-lg input[type="month"]{line-height:53px}}.form-group{margin-bottom:15px}.radio,.checkbox{position:relative;display:block;margin-top:10px;margin-bottom:10px}.radio label,.checkbox label{min-height:25px;padding-left:20px;margin-bottom:0;font-weight:normal;cursor:pointer}.radio input[type="radio"],.radio-inline input[type="radio"],.checkbox input[type="checkbox"],.checkbox-inline input[type="checkbox"]{position:absolute;margin-left:-20px;margin-top:4px \9}.radio+.radio,.checkbox+.checkbox{margin-top:-5px}.radio-inline,.checkbox-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;vertical-align:middle;font-weight:normal;cursor:pointer}.radio-inline+.radio-inline,.checkbox-inline+.checkbox-inline{margin-top:0;margin-left:10px}input[type="radio"][disabled],input[type="checkbox"][disabled],input[type="radio"].disabled,input[type="checkbox"].disabled,fieldset[disabled] input[type="radio"],fieldset[disabled] input[type="checkbox"]{cursor:not-allowed}.radio-inline.disabled,.checkbox-inline.disabled,fieldset[disabled] .radio-inline,fieldset[disabled] .checkbox-inline{cursor:not-allowed}.radio.disabled label,.checkbox.disabled label,fieldset[disabled] .radio label,fieldset[disabled] .checkbox label{cursor:not-allowed}.form-control-static{padding-top:7px;padding-bottom:7px;margin-bottom:0;min-height:43px}.form-control-static.input-lg,.form-control-static.input-sm{padding-left:0;padding-right:0}.input-sm{height:36px;padding:5px 10px;font-size:16px;line-height:1.5;border-radius:3px}select.input-sm{height:36px;line-height:36px}textarea.input-sm,select[multiple].input-sm{height:auto}.form-group-sm .form-control{height:36px;padding:5px 10px;font-size:16px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:36px;line-height:36px}.form-group-sm textarea.form-control,.form-group-sm select[multiple].form-control{height:auto}.form-group-sm .form-control-static{height:36px;min-height:41px;padding:6px 10px;font-size:16px;line-height:1.5}.input-lg{height:53px;padding:10px 16px;font-size:23px;line-height:1.3333333;border-radius:6px}select.input-lg{height:53px;line-height:53px}textarea.input-lg,select[multiple].input-lg{height:auto}.form-group-lg .form-control{height:53px;padding:10px 16px;font-size:23px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:53px;line-height:53px}.form-group-lg textarea.form-control,.form-group-lg select[multiple].form-control{height:auto}.form-group-lg .form-control-static{height:53px;min-height:48px;padding:11px 16px;font-size:23px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:48.75px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:39px;height:39px;line-height:39px;text-align:center;pointer-events:none}.input-lg+.form-control-feedback,.input-group-lg+.form-control-feedback,.form-group-lg .form-control+.form-control-feedback{width:53px;height:53px;line-height:53px}.input-sm+.form-control-feedback,.input-group-sm+.form-control-feedback,.form-group-sm .form-control+.form-control-feedback{width:36px;height:36px;line-height:36px}.has-success .help-block,.has-success .control-label,.has-success .radio,.has-success .checkbox,.has-success .radio-inline,.has-success .checkbox-inline,.has-success.radio label,.has-success.checkbox label,.has-success.radio-inline label,.has-success.checkbox-inline label{color:#678b35}.has-success .form-control{border-color:#678b35;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-success .form-control:focus{border-color:#4b6627;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #9bc363;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #9bc363}.has-success .input-group-addon{color:#678b35;border-color:#678b35;background-color:#e2eed1}.has-success .form-control-feedback{color:#678b35}.has-warning .help-block,.has-warning .control-label,.has-warning .radio,.has-warning .checkbox,.has-warning .radio-inline,.has-warning .checkbox-inline,.has-warning.radio label,.has-warning.checkbox label,.has-warning.radio-inline label,.has-warning.checkbox-inline label{color:#c09853}.has-warning .form-control{border-color:#c09853;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-warning .form-control:focus{border-color:#a47e3c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #dbc59e;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #dbc59e}.has-warning .input-group-addon{color:#c09853;border-color:#c09853;background-color:#fcf8e3}.has-warning .form-control-feedback{color:#c09853}.has-error .help-block,.has-error .control-label,.has-error .radio,.has-error .checkbox,.has-error .radio-inline,.has-error .checkbox-inline,.has-error.radio label,.has-error.checkbox label,.has-error.radio-inline label,.has-error.checkbox-inline label{color:#b94a48}.has-error .form-control{border-color:#b94a48;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-error .form-control:focus{border-color:#953b39;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #d59392;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #d59392}.has-error .input-group-addon{color:#b94a48;border-color:#b94a48;background-color:#f2dede}.has-error .form-control-feedback{color:#b94a48}.has-feedback label ~ .form-control-feedback{top:30px}.has-feedback label.sr-only ~ .form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#959595}@media(min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn,.form-inline .input-group .form-control{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .radio,.form-inline .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .radio label,.form-inline .checkbox label{padding-left:0}.form-inline .radio input[type="radio"],.form-inline .checkbox input[type="checkbox"]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .radio,.form-horizontal .checkbox,.form-horizontal .radio-inline,.form-horizontal .checkbox-inline{margin-top:0;margin-bottom:0;padding-top:7px}.form-horizontal .radio,.form-horizontal .checkbox{min-height:32px}.form-horizontal .form-group{margin-left:-15px;margin-right:-15px}@media(min-width:768px){.form-horizontal .control-label{text-align:right;margin-bottom:0;padding-top:7px}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media(min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:14.333333px;font-size:23px}}@media(min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:16px}}.btn{display:inline-block;margin-bottom:0;font-weight:normal;text-align:center;vertical-align:middle;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;padding:6px 12px;font-size:18px;line-height:1.428571429;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.btn:focus,.btn:active:focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn.active.focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn:hover,.btn:focus,.btn.focus{color:#333;text-decoration:none}.btn:active,.btn.active{outline:0;background-image:none;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;opacity:.65;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default:focus,.btn-default.focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active,.btn-default.active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active:hover,.btn-default.active:hover,.open>.dropdown-toggle.btn-default:hover,.btn-default:active:focus,.btn-default.active:focus,.open>.dropdown-toggle.btn-default:focus,.btn-default:active.focus,.btn-default.active.focus,.open>.dropdown-toggle.btn-default.focus{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default:active,.btn-default.active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled,.btn-default[disabled],fieldset[disabled] .btn-default,.btn-default.disabled:hover,.btn-default[disabled]:hover,fieldset[disabled] .btn-default:hover,.btn-default.disabled:focus,.btn-default[disabled]:focus,fieldset[disabled] .btn-default:focus,.btn-default.disabled.focus,.btn-default[disabled].focus,fieldset[disabled] .btn-default.focus,.btn-default.disabled:active,.btn-default[disabled]:active,fieldset[disabled] .btn-default:active,.btn-default.disabled.active,.btn-default[disabled].active,fieldset[disabled] .btn-default.active{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#82b043;border-color:#749e3c}.btn-primary:focus,.btn-primary.focus{color:#fff;background-color:#678b35;border-color:#304119}.btn-primary:hover{color:#fff;background-color:#678b35;border-color:#54712b}.btn-primary:active,.btn-primary.active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#678b35;border-color:#54712b}.btn-primary:active:hover,.btn-primary.active:hover,.open>.dropdown-toggle.btn-primary:hover,.btn-primary:active:focus,.btn-primary.active:focus,.open>.dropdown-toggle.btn-primary:focus,.btn-primary:active.focus,.btn-primary.active.focus,.open>.dropdown-toggle.btn-primary.focus{color:#fff;background-color:#54712b;border-color:#304119}.btn-primary:active,.btn-primary.active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled,.btn-primary[disabled],fieldset[disabled] .btn-primary,.btn-primary.disabled:hover,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary:hover,.btn-primary.disabled:focus,.btn-primary[disabled]:focus,fieldset[disabled] .btn-primary:focus,.btn-primary.disabled.focus,.btn-primary[disabled].focus,fieldset[disabled] .btn-primary.focus,.btn-primary.disabled:active,.btn-primary[disabled]:active,fieldset[disabled] .btn-primary:active,.btn-primary.disabled.active,.btn-primary[disabled].active,fieldset[disabled] .btn-primary.active{background-color:#82b043;border-color:#749e3c}.btn-primary .badge{color:#82b043;background-color:#fff}.btn-success{color:#fff;background-color:#82b043;border-color:#749e3c}.btn-success:focus,.btn-success.focus{color:#fff;background-color:#678b35;border-color:#304119}.btn-success:hover{color:#fff;background-color:#678b35;border-color:#54712b}.btn-success:active,.btn-success.active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#678b35;border-color:#54712b}.btn-success:active:hover,.btn-success.active:hover,.open>.dropdown-toggle.btn-success:hover,.btn-success:active:focus,.btn-success.active:focus,.open>.dropdown-toggle.btn-success:focus,.btn-success:active.focus,.btn-success.active.focus,.open>.dropdown-toggle.btn-success.focus{color:#fff;background-color:#54712b;border-color:#304119}.btn-success:active,.btn-success.active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled,.btn-success[disabled],fieldset[disabled] .btn-success,.btn-success.disabled:hover,.btn-success[disabled]:hover,fieldset[disabled] .btn-success:hover,.btn-success.disabled:focus,.btn-success[disabled]:focus,fieldset[disabled] .btn-success:focus,.btn-success.disabled.focus,.btn-success[disabled].focus,fieldset[disabled] .btn-success.focus,.btn-success.disabled:active,.btn-success[disabled]:active,fieldset[disabled] .btn-success:active,.btn-success.disabled.active,.btn-success[disabled].active,fieldset[disabled] .btn-success.active{background-color:#82b043;border-color:#749e3c}.btn-success .badge{color:#82b043;background-color:#fff}.btn-info{color:#fff;background-color:#3776ab;border-color:#316998}.btn-info:focus,.btn-info.focus{color:#fff;background-color:#2b5b84;border-color:#122637}.btn-info:hover{color:#fff;background-color:#2b5b84;border-color:#224969}.btn-info:active,.btn-info.active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#2b5b84;border-color:#224969}.btn-info:active:hover,.btn-info.active:hover,.open>.dropdown-toggle.btn-info:hover,.btn-info:active:focus,.btn-info.active:focus,.open>.dropdown-toggle.btn-info:focus,.btn-info:active.focus,.btn-info.active.focus,.open>.dropdown-toggle.btn-info.focus{color:#fff;background-color:#224969;border-color:#122637}.btn-info:active,.btn-info.active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled,.btn-info[disabled],fieldset[disabled] .btn-info,.btn-info.disabled:hover,.btn-info[disabled]:hover,fieldset[disabled] .btn-info:hover,.btn-info.disabled:focus,.btn-info[disabled]:focus,fieldset[disabled] .btn-info:focus,.btn-info.disabled.focus,.btn-info[disabled].focus,fieldset[disabled] .btn-info.focus,.btn-info.disabled:active,.btn-info[disabled]:active,fieldset[disabled] .btn-info:active,.btn-info.disabled.active,.btn-info[disabled].active,fieldset[disabled] .btn-info.active{background-color:#3776ab;border-color:#316998}.btn-info .badge{color:#3776ab;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning:focus,.btn-warning.focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active,.btn-warning.active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active:hover,.btn-warning.active:hover,.open>.dropdown-toggle.btn-warning:hover,.btn-warning:active:focus,.btn-warning.active:focus,.open>.dropdown-toggle.btn-warning:focus,.btn-warning:active.focus,.btn-warning.active.focus,.open>.dropdown-toggle.btn-warning.focus{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning:active,.btn-warning.active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-warning,.btn-warning.disabled:hover,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning:hover,.btn-warning.disabled:focus,.btn-warning[disabled]:focus,fieldset[disabled] .btn-warning:focus,.btn-warning.disabled.focus,.btn-warning[disabled].focus,fieldset[disabled] .btn-warning.focus,.btn-warning.disabled:active,.btn-warning[disabled]:active,fieldset[disabled] .btn-warning:active,.btn-warning.disabled.active,.btn-warning[disabled].active,fieldset[disabled] .btn-warning.active{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger:focus,.btn-danger.focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active,.btn-danger.active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active:hover,.btn-danger.active:hover,.open>.dropdown-toggle.btn-danger:hover,.btn-danger:active:focus,.btn-danger.active:focus,.open>.dropdown-toggle.btn-danger:focus,.btn-danger:active.focus,.btn-danger.active.focus,.open>.dropdown-toggle.btn-danger.focus{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger:active,.btn-danger.active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled,.btn-danger[disabled],fieldset[disabled] .btn-danger,.btn-danger.disabled:hover,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger:hover,.btn-danger.disabled:focus,.btn-danger[disabled]:focus,fieldset[disabled] .btn-danger:focus,.btn-danger.disabled.focus,.btn-danger[disabled].focus,fieldset[disabled] .btn-danger.focus,.btn-danger.disabled:active,.btn-danger[disabled]:active,fieldset[disabled] .btn-danger:active,.btn-danger.disabled.active,.btn-danger[disabled].active,fieldset[disabled] .btn-danger.active{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{color:#3776ab;font-weight:normal;border-radius:0}.btn-link,.btn-link:active,.btn-link.active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:hover,.btn-link:focus,.btn-link:active{border-color:transparent}.btn-link:hover,.btn-link:focus{color:#244e71;text-decoration:underline;background-color:transparent}.btn-link[disabled]:hover,fieldset[disabled] .btn-link:hover,.btn-link[disabled]:focus,fieldset[disabled] .btn-link:focus{color:#777;text-decoration:none}.btn-lg,.btn-group-lg>.btn{padding:10px 16px;font-size:23px;line-height:1.3333333;border-radius:6px}.btn-sm,.btn-group-sm>.btn{padding:5px 10px;font-size:16px;line-height:1.5;border-radius:3px}.btn-xs,.btn-group-xs>.btn{padding:1px 5px;font-size:16px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-property:height,visibility;transition-property:height,visibility;-webkit-transition-duration:.35s;transition-duration:.35s;-webkit-transition-timing-function:ease;transition-timing-function:ease}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid \9;border-right:4px solid transparent;border-left:4px solid transparent}.dropup,.dropdown{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;list-style:none;font-size:18px;text-align:left;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,0.175);box-shadow:0 6px 12px rgba(0,0,0,0.175);background-clip:padding-box}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:11.5px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:normal;line-height:1.428571429;color:#333;white-space:nowrap}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{text-decoration:none;color:#262626;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{color:#fff;text-decoration:none;outline:0;background-color:#82b043}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{color:#777}.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{text-decoration:none;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);cursor:not-allowed}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{left:auto;right:0}.dropdown-menu-left{left:0;right:auto}.dropdown-header{display:block;padding:3px 20px;font-size:16px;line-height:1.428571429;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;left:0;right:0;bottom:0;top:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-top:0;border-bottom:4px dashed;border-bottom:4px solid \9;content:""}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media(min-width:768px){.navbar-right .dropdown-menu{left:auto;right:0}.navbar-right .dropdown-menu-left{left:0;right:auto}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;float:left}.btn-group>.btn:hover,.btn-group-vertical>.btn:hover,.btn-group>.btn:focus,.btn-group-vertical>.btn:focus,.btn-group>.btn:active,.btn-group-vertical>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn.active{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-bottom-left-radius:0;border-top-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-left:8px;padding-right:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-left:12px;padding-right:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-bottom-left-radius:4px;border-top-right-radius:0;border-top-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-right-radius:0;border-top-left-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{float:none;display:table-cell;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle="buttons"]>.btn input[type="radio"],[data-toggle="buttons"]>.btn-group>.btn input[type="radio"],[data-toggle="buttons"]>.btn input[type="checkbox"],[data-toggle="buttons"]>.btn-group>.btn input[type="checkbox"]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*="col-"]{float:none;padding-left:0;padding-right:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:53px;padding:10px 16px;font-size:23px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:53px;line-height:53px}textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn,select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:36px;padding:5px 10px;font-size:16px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:36px;line-height:36px}textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn,select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn{height:auto}.input-group-addon,.input-group-btn,.input-group .form-control{display:table-cell}.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child),.input-group .form-control:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:18px;font-weight:normal;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:16px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:23px;border-radius:6px}.input-group-addon input[type="radio"],.input-group-addon input[type="checkbox"]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group-btn:last-child>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-top-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:first-child>.btn-group:not(:first-child)>.btn{border-bottom-left-radius:0;border-top-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:hover,.input-group-btn>.btn:focus,.input-group-btn>.btn:active{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{margin-bottom:0;padding-left:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:5px 10px}.nav>li>a:hover,.nav>li>a:focus{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:hover,.nav>li.disabled>a:focus{color:#777;text-decoration:none;background-color:transparent;cursor:not-allowed}.nav .open>a,.nav .open>a:hover,.nav .open>a:focus{background-color:#eee;border-color:#3776ab}.nav .nav-divider{height:1px;margin:11.5px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.428571429;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{color:#555;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent;cursor:default}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media(min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border:1px solid #ddd}@media(min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:hover,.nav-pills>li.active>a:focus{color:#fff;background-color:#82b043}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media(min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border:1px solid #ddd}@media(min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-right-radius:0;border-top-left-radius:0}.navbar{position:relative;min-height:40px;margin-bottom:25px;border:1px solid transparent}@media(min-width:768px){.navbar{border-radius:4px}}@media(min-width:768px){.navbar-header{float:left}}.navbar-collapse{overflow-x:visible;padding-right:15px;padding-left:15px;border-top:1px solid transparent;box-shadow:inset 0 1px 0 rgba(255,255,255,0.1);-webkit-overflow-scrolling:touch}.navbar-collapse.in{overflow-y:auto}@media(min-width:768px){.navbar-collapse{width:auto;border-top:0;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{padding-left:0;padding-right:0}}.navbar-fixed-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{max-height:340px}@media(max-device-width:480px) and (orientation:landscape){.navbar-fixed-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{max-height:200px}}.container>.navbar-header,.container-fluid>.navbar-header,.container>.navbar-collapse,.container-fluid>.navbar-collapse{margin-right:-15px;margin-left:-15px}@media(min-width:768px){.container>.navbar-header,.container-fluid>.navbar-header,.container>.navbar-collapse,.container-fluid>.navbar-collapse{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media(min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-top,.navbar-fixed-bottom{position:fixed;right:0;left:0;z-index:1030}@media(min-width:768px){.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;padding:7.5px 15px;font-size:23px;line-height:25px;height:40px}.navbar-brand:hover,.navbar-brand:focus{text-decoration:none}.navbar-brand>img{display:block}@media(min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;margin-right:15px;padding:9px 10px;margin-top:3px;margin-bottom:3px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media(min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:3.75px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:25px}@media(max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;box-shadow:none}.navbar-nav .open .dropdown-menu>li>a,.navbar-nav .open .dropdown-menu .dropdown-header{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:25px}.navbar-nav .open .dropdown-menu>li>a:hover,.navbar-nav .open .dropdown-menu>li>a:focus{background-image:none}}@media(min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:7.5px;padding-bottom:7.5px}}.navbar-form{margin-left:-15px;margin-right:-15px;padding:10px 15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-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);margin-top:.5px;margin-bottom:.5px}@media(min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn,.navbar-form .input-group .form-control{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .radio,.navbar-form .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .radio label,.navbar-form .checkbox label{padding-left:0}.navbar-form .radio input[type="radio"],.navbar-form .checkbox input[type="checkbox"]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media(max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media(min-width:768px){.navbar-form{width:auto;border:0;margin-left:0;margin-right:0;padding-top:0;padding-bottom:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-right-radius:0;border-top-left-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-right-radius:4px;border-top-left-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:.5px;margin-bottom:.5px}.navbar-btn.btn-sm{margin-top:2px;margin-bottom:2px}.navbar-btn.btn-xs{margin-top:9px;margin-bottom:9px}.navbar-text{margin-top:7.5px;margin-bottom:7.5px}@media(min-width:768px){.navbar-text{float:left;margin-left:15px;margin-right:15px}}@media(min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right ~ .navbar-right{margin-right:0}}.navbar-default{background-color:#fff;border-color:#eee}.navbar-default .navbar-brand{color:#aaa}.navbar-default .navbar-brand:hover,.navbar-default .navbar-brand:focus{color:#919191;background-color:transparent}.navbar-default .navbar-text{color:#555}.navbar-default .navbar-nav>li>a{color:#555}.navbar-default .navbar-nav>li>a:hover,.navbar-default .navbar-nav>li>a:focus{color:#3776ab;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:hover,.navbar-default .navbar-nav>.active>a:focus{color:#3776ab;background-color:#f7f7f7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:hover,.navbar-default .navbar-nav>.disabled>a:focus{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:hover,.navbar-default .navbar-toggle:focus{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#eee}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:hover,.navbar-default .navbar-nav>.open>a:focus{background-color:#f7f7f7;color:#3776ab}@media(max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#555}.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus{color:#3776ab;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus{color:#3776ab;background-color:#f7f7f7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#555}.navbar-default .navbar-link:hover{color:#3776ab}.navbar-default .btn-link{color:#555}.navbar-default .btn-link:hover,.navbar-default .btn-link:focus{color:#3776ab}.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:hover,.navbar-default .btn-link[disabled]:focus,fieldset[disabled] .navbar-default .btn-link:focus{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:hover,.navbar-inverse .navbar-brand:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:hover,.navbar-inverse .navbar-nav>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:hover,.navbar-inverse .navbar-nav>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:hover,.navbar-inverse .navbar-nav>.disabled>a:focus{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:hover,.navbar-inverse .navbar-toggle:focus{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:hover,.navbar-inverse .navbar-nav>.open>a:focus{background-color:#080808;color:#fff}@media(max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:hover,.navbar-inverse .btn-link:focus{color:#fff}.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:hover,.navbar-inverse .btn-link[disabled]:focus,fieldset[disabled] .navbar-inverse .btn-link:focus{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:25px;list-style:none;background-color:#fff;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{content:"/\00a0";padding:0 5px;color:#ccc}.breadcrumb>.active{color:#aaa}.pagination{display:inline-block;padding-left:0;margin:25px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;line-height:1.428571429;text-decoration:none;color:#3776ab;background-color:#fff;border:1px solid #ddd;margin-left:-1px}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-bottom-left-radius:4px;border-top-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-bottom-right-radius:4px;border-top-right-radius:4px}.pagination>li>a:hover,.pagination>li>span:hover,.pagination>li>a:focus,.pagination>li>span:focus{z-index:3;color:#244e71;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>span,.pagination>.active>a:hover,.pagination>.active>span:hover,.pagination>.active>a:focus,.pagination>.active>span:focus{z-index:2;color:#fff;background-color:#82b043;border-color:#82b043;cursor:default}.pagination>.disabled>span,.pagination>.disabled>span:hover,.pagination>.disabled>span:focus,.pagination>.disabled>a,.pagination>.disabled>a:hover,.pagination>.disabled>a:focus{color:#777;background-color:#fff;border-color:#ddd;cursor:not-allowed}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:23px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-bottom-right-radius:6px;border-top-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:16px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-bottom-left-radius:3px;border-top-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-bottom-right-radius:3px;border-top-right-radius:3px}.pager{padding-left:0;margin:25px 0;list-style:none;text-align:center}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:hover,.pager li>a:focus{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:hover,.pager .disabled>a:focus,.pager .disabled>span{color:#777;background-color:#fff;cursor:not-allowed}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:bold;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:hover,a.label:focus{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:hover,.label-default[href]:focus{background-color:#5e5e5e}.label-primary{background-color:#82b043}.label-primary[href]:hover,.label-primary[href]:focus{background-color:#678b35}.label-success{background-color:#82b043}.label-success[href]:hover,.label-success[href]:focus{background-color:#678b35}.label-info{background-color:#3776ab}.label-info[href]:hover,.label-info[href]:focus{background-color:#2b5b84}.label-warning{background-color:#f0ad4e}.label-warning[href]:hover,.label-warning[href]:focus{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:hover,.label-danger[href]:focus{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:16px;font-weight:bold;color:#fff;line-height:1;vertical-align:middle;white-space:nowrap;text-align:center;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-xs .badge,.btn-group-xs>.btn .badge{top:0;padding:1px 5px}a.badge:hover,a.badge:focus{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#3776ab;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#ffd343}.jumbotron h1,.jumbotron .h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:27px;font-weight:200}.jumbotron>hr{border-top-color:#ffc710}.container .jumbotron,.container-fluid .jumbotron{border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-left:60px;padding-right:60px}.jumbotron h1,.jumbotron .h1{font-size:81px}}.thumbnail{display:block;padding:4px;margin-bottom:25px;line-height:1.428571429;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail>img,.thumbnail a>img{margin-left:auto;margin-right:auto}a.thumbnail:hover,a.thumbnail:focus,a.thumbnail.active{border-color:#3776ab}.thumbnail .caption{padding:9px;color:#555}.alert{padding:15px;margin-bottom:25px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:bold}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{background-color:#e2eed1;border-color:#dce7bf;color:#678b35}.alert-success hr{border-top-color:#d3e0ac}.alert-success .alert-link{color:#4b6627}.alert-info{background-color:#c2d9ec;border-color:#a7d2e3;color:#3776ab}.alert-info hr{border-top-color:#94c8dd}.alert-info .alert-link{color:#2b5b84}.alert-warning{background-color:#fcf8e3;border-color:#faebcc;color:#c09853}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#a47e3c}.alert-danger{background-color:#f2dede;border-color:#ebccd1;color:#b94a48}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#953b39}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{overflow:hidden;height:25px;margin-bottom:25px;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);box-shadow:inset 0 1px 2px rgba(0,0,0,0.1)}.progress-bar{float:left;width:0;height:100%;font-size:16px;line-height:25px;color:#fff;text-align:center;background-color:#82b043;-webkit-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-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-striped .progress-bar,.progress-bar-striped{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:-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);background-size:40px 40px}.progress.active .progress-bar,.progress-bar.active{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#82b043}.progress-striped .progress-bar-success{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:-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-bar-info{background-color:#3776ab}.progress-striped .progress-bar-info{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:-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-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{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:-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-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{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:-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)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{zoom:1;overflow:hidden}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-left,.media-right,.media-body{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{margin-bottom:20px;padding-left:0}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-right-radius:4px;border-top-left-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:hover,button.list-group-item:hover,a.list-group-item:focus,button.list-group-item:focus{text-decoration:none;color:#555;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:hover,.list-group-item.disabled:focus{background-color:#eee;color:#777;cursor:not-allowed}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{z-index:2;color:#fff;background-color:#82b043;border-color:#82b043}.list-group-item.active .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>.small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:hover .list-group-item-text,.list-group-item.active:focus .list-group-item-text{color:#e2eed1}.list-group-item-success{color:#678b35;background-color:#e2eed1}a.list-group-item-success,button.list-group-item-success{color:#678b35}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:hover,button.list-group-item-success:hover,a.list-group-item-success:focus,button.list-group-item-success:focus{color:#678b35;background-color:#d6e7bf}a.list-group-item-success.active,button.list-group-item-success.active,a.list-group-item-success.active:hover,button.list-group-item-success.active:hover,a.list-group-item-success.active:focus,button.list-group-item-success.active:focus{color:#fff;background-color:#678b35;border-color:#678b35}.list-group-item-info{color:#3776ab;background-color:#c2d9ec}a.list-group-item-info,button.list-group-item-info{color:#3776ab}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:hover,button.list-group-item-info:hover,a.list-group-item-info:focus,button.list-group-item-info:focus{color:#3776ab;background-color:#afcde5}a.list-group-item-info.active,button.list-group-item-info.active,a.list-group-item-info.active:hover,button.list-group-item-info.active:hover,a.list-group-item-info.active:focus,button.list-group-item-info.active:focus{color:#fff;background-color:#3776ab;border-color:#3776ab}.list-group-item-warning{color:#c09853;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#c09853}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:hover,button.list-group-item-warning:hover,a.list-group-item-warning:focus,button.list-group-item-warning:focus{color:#c09853;background-color:#faf2cc}a.list-group-item-warning.active,button.list-group-item-warning.active,a.list-group-item-warning.active:hover,button.list-group-item-warning.active:hover,a.list-group-item-warning.active:focus,button.list-group-item-warning.active:focus{color:#fff;background-color:#c09853;border-color:#c09853}.list-group-item-danger{color:#b94a48;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#b94a48}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:hover,button.list-group-item-danger:hover,a.list-group-item-danger:focus,button.list-group-item-danger:focus{color:#b94a48;background-color:#ebcccc}a.list-group-item-danger.active,button.list-group-item-danger.active,a.list-group-item-danger.active:hover,button.list-group-item-danger.active:hover,a.list-group-item-danger.active:focus,button.list-group-item-danger.active:focus{color:#fff;background-color:#b94a48;border-color:#b94a48}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:25px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,0.05);box-shadow:0 1px 1px rgba(0,0,0,0.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-right-radius:3px;border-top-left-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:21px;color:inherit}.panel-title>a,.panel-title>small,.panel-title>.small,.panel-title>small>a,.panel-title>.small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-right-radius:3px;border-top-left-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-right-radius:0;border-top-left-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.table,.panel>.table-responsive>.table,.panel>.panel-collapse>.table{margin-bottom:0}.panel>.table caption,.panel>.table-responsive>.table caption,.panel>.panel-collapse>.table caption{padding-left:15px;padding-right:15px}.panel>.table:first-child,.panel>.table-responsive:first-child>.table:first-child{border-top-right-radius:3px;border-top-left-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table:last-child,.panel>.table-responsive:last-child>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-left-radius:3px;border-bottom-right-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child th,.panel>.table>tbody:first-child>tr:first-child td{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{border:0;margin-bottom:0}.panel-group{margin-bottom:25px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.panel-body,.panel-group .panel-heading+.panel-collapse>.list-group{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#4d4d4d;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#4d4d4d}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#82b043}.panel-primary>.panel-heading{color:#fff;background-color:#82b043;border-color:#82b043}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#82b043}.panel-primary>.panel-heading .badge{color:#82b043;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#82b043}.panel-success{border-color:#dce7bf}.panel-success>.panel-heading{color:#678b35;background-color:#e2eed1;border-color:#dce7bf}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#dce7bf}.panel-success>.panel-heading .badge{color:#e2eed1;background-color:#678b35}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#dce7bf}.panel-info{border-color:#a7d2e3}.panel-info>.panel-heading{color:#3776ab;background-color:#c2d9ec;border-color:#a7d2e3}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#a7d2e3}.panel-info>.panel-heading .badge{color:#c2d9ec;background-color:#3776ab}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#a7d2e3}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#c09853;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#c09853}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#b94a48;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#b94a48}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive iframe,.embed-responsive embed,.embed-responsive object,.embed-responsive video{position:absolute;top:0;left:0;bottom:0;height:100%;width:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f6f6f6;border:1px solid #e9f1f8;border-radius:4px;-webkit-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)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:27px;font-weight:bold;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.2;filter:alpha(opacity=20)}.close:hover,.close:focus{color:#000;text-decoration:none;cursor:pointer;opacity:.5;filter:alpha(opacity=50)}button.close{padding:0;cursor:pointer;background:transparent;border:0;-webkit-appearance:none}.modal-open{overflow:hidden}.modal{display:none;overflow:hidden;position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%);-webkit-transition:-webkit-transform .3s ease-out;-moz-transition:-moz-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;border:1px solid #999;border:1px solid rgba(0,0,0,0.2);border-radius:6px;-webkit-box-shadow:0 3px 9px rgba(0,0,0,0.5);box-shadow:0 3px 9px rgba(0,0,0,0.5);background-clip:padding-box;outline:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{opacity:0;filter:alpha(opacity=0)}.modal-backdrop.in{opacity:.5;filter:alpha(opacity=50)}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5;min-height:16.428571429px}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.428571429}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-left:5px;margin-bottom:0}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media(min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,0.5);box-shadow:0 5px 15px rgba(0,0,0,0.5)}.modal-sm{width:300px}}@media(min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:Georgia,"Times New Roman",Times,serif;font-style:normal;font-weight:normal;letter-spacing:normal;line-break:auto;line-height:1.428571429;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:16px;opacity:0;filter:alpha(opacity=0)}.tooltip.in{opacity:.9;filter:alpha(opacity=90)}.tooltip.top{margin-top:-3px;padding:5px 0}.tooltip.right{margin-left:3px;padding:0 5px}.tooltip.bottom{margin-top:3px;padding:5px 0}.tooltip.left{margin-left:-3px;padding:0 5px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{bottom:0;right:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:Georgia,"Times New Roman",Times,serif;font-style:normal;font-weight:normal;letter-spacing:normal;line-break:auto;line-height:1.428571429;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:18px;background-color:#fff;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,0.2);box-shadow:0 5px 10px rgba(0,0,0,0.2)}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{margin:0;padding:8px 14px;font-size:18px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{border-width:10px;content:""}.popover.top>.arrow{left:50%;margin-left:-11px;border-bottom-width:0;border-top-color:#999;border-top-color:rgba(0,0,0,0.25);bottom:-11px}.popover.top>.arrow:after{content:" ";bottom:1px;margin-left:-10px;border-bottom-width:0;border-top-color:#fff}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-left-width:0;border-right-color:#999;border-right-color:rgba(0,0,0,0.25)}.popover.right>.arrow:after{content:" ";left:1px;bottom:-10px;border-left-width:0;border-right-color:#fff}.popover.bottom>.arrow{left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,0.25);top:-11px}.popover.bottom>.arrow:after{content:" ";top:1px;margin-left:-10px;border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,0.25)}.popover.left>.arrow:after{content:" ";right:1px;border-right-width:0;border-left-color:#fff;bottom:-10px}.carousel{position:relative}.carousel-inner{position:relative;overflow:hidden;width:100%}.carousel-inner>.item{display:none;position:relative;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>img,.carousel-inner>.item>a>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-moz-transition:-moz-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;-moz-perspective:1000px;perspective:1000px}.carousel-inner>.item.next,.carousel-inner>.item.active.right{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);left:0}.carousel-inner>.item.prev,.carousel-inner>.item.active.left{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);left:0}.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right,.carousel-inner>.item.active{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);left:0}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;left:0;bottom:0;width:15%;opacity:.5;filter:alpha(opacity=50);font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6)}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,0.5) 0,rgba(0,0,0,0.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,0.5) 0,rgba(0,0,0,0.0001) 100%);background-image:linear-gradient(to right,rgba(0,0,0,0.5) 0,rgba(0,0,0,0.0001) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000',endColorstr='#00000000',GradientType=1)}.carousel-control.right{left:auto;right:0;background-image:-webkit-linear-gradient(left,rgba(0,0,0,0.0001) 0,rgba(0,0,0,0.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,0.0001) 0,rgba(0,0,0,0.5) 100%);background-image:linear-gradient(to right,rgba(0,0,0,0.0001) 0,rgba(0,0,0,0.5) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000',endColorstr='#80000000',GradientType=1)}.carousel-control:hover,.carousel-control:focus{outline:0;color:#fff;text-decoration:none;opacity:.9;filter:alpha(opacity=90)}.carousel-control .icon-prev,.carousel-control .icon-next,.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right{position:absolute;top:50%;margin-top:-10px;z-index:5;display:inline-block}.carousel-control .icon-prev,.carousel-control .glyphicon-chevron-left{left:50%;margin-left:-10px}.carousel-control .icon-next,.carousel-control .glyphicon-chevron-right{right:50%;margin-right:-10px}.carousel-control .icon-prev,.carousel-control .icon-next{width:20px;height:20px;line-height:1;font-family:serif}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;margin-left:-30%;padding-left:0;list-style:none;text-align:center}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;border:1px solid #fff;border-radius:10px;cursor:pointer;background-color:#000 \9;background-color:rgba(0,0,0,0)}.carousel-indicators .active{margin:0;width:12px;height:12px;background-color:#fff}.carousel-caption{position:absolute;left:15%;right:15%;bottom:20px;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-prev,.carousel-control .icon-next{width:30px;height:30px;margin-top:-15px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-15px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-15px}.carousel-caption{left:20%;right:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.clearfix:before,.clearfix:after,.dl-horizontal dd:before,.dl-horizontal dd:after,.container:before,.container:after,.container-fluid:before,.container-fluid:after,.row:before,.row:after,.form-horizontal .form-group:before,.form-horizontal .form-group:after,.btn-toolbar:before,.btn-toolbar:after,.btn-group-vertical>.btn-group:before,.btn-group-vertical>.btn-group:after,.nav:before,.nav:after,.navbar:before,.navbar:after,.navbar-header:before,.navbar-header:after,.navbar-collapse:before,.navbar-collapse:after,.pager:before,.pager:after,.panel-body:before,.panel-body:after,.modal-footer:before,.modal-footer:after{content:" ";display:table}.clearfix:after,.dl-horizontal dd:after,.container:after,.container-fluid:after,.row:after,.form-horizontal .form-group:after,.btn-toolbar:after,.btn-group-vertical>.btn-group:after,.nav:after,.navbar:after,.navbar-header:after,.navbar-collapse:after,.pager:after,.panel-body:after,.modal-footer:after{clear:both}.center-block{display:block;margin-left:auto;margin-right:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-xs,.visible-sm,.visible-md,.visible-lg{display:none!important}.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block{display:none!important}@media(max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}th.visible-xs,td.visible-xs{display:table-cell!important}}@media(max-width:767px){.visible-xs-block{display:block!important}}@media(max-width:767px){.visible-xs-inline{display:inline!important}}@media(max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media(min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}th.visible-sm,td.visible-sm{display:table-cell!important}}@media(min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media(min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media(min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media(min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}th.visible-md,td.visible-md{display:table-cell!important}}@media(min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media(min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media(min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media(min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}th.visible-lg,td.visible-lg{display:table-cell!important}}@media(min-width:1200px){.visible-lg-block{display:block!important}}@media(min-width:1200px){.visible-lg-inline{display:inline!important}}@media(min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media(max-width:767px){.hidden-xs{display:none!important}}@media(min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media(min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media(min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}th.visible-print,td.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}}
\ No newline at end of file
diff --git a/data/static_src/css/foundation-icons.css b/data/static_src/css/foundation-icons.css
deleted file mode 100644
index 127989cc..00000000
--- a/data/static_src/css/foundation-icons.css
+++ /dev/null
@@ -1 +0,0 @@
-@font-face{font-family:foundation-icons;src:url(../fonts/foundation-icons.eot);src:url(../fonts/foundation-icons.eot?#iefix) format("embedded-opentype"),url(../fonts/foundation-icons.woff) format("woff"),url(../fonts/foundation-icons.ttf) format("truetype"),url(../fonts/foundation-icons.svg#fontcustom) format("svg");font-weight:400;font-style:normal}.fi-address-book:before,.fi-alert:before,.fi-align-center:before,.fi-align-justify:before,.fi-align-left:before,.fi-align-right:before,.fi-anchor:before,.fi-annotate:before,.fi-archive:before,.fi-arrow-down:before,.fi-arrow-left:before,.fi-arrow-right:before,.fi-arrow-up:before,.fi-arrows-compress:before,.fi-arrows-expand:before,.fi-arrows-in:before,.fi-arrows-out:before,.fi-asl:before,.fi-asterisk:before,.fi-at-sign:before,.fi-background-color:before,.fi-battery-empty:before,.fi-battery-full:before,.fi-battery-half:before,.fi-bitcoin-circle:before,.fi-bitcoin:before,.fi-blind:before,.fi-bluetooth:before,.fi-bold:before,.fi-book-bookmark:before,.fi-book:before,.fi-bookmark:before,.fi-braille:before,.fi-burst-new:before,.fi-burst-sale:before,.fi-burst:before,.fi-calendar:before,.fi-camera:before,.fi-check:before,.fi-checkbox:before,.fi-clipboard-notes:before,.fi-clipboard-pencil:before,.fi-clipboard:before,.fi-clock:before,.fi-closed-caption:before,.fi-cloud:before,.fi-comment-minus:before,.fi-comment-quotes:before,.fi-comment-video:before,.fi-comment:before,.fi-comments:before,.fi-compass:before,.fi-contrast:before,.fi-credit-card:before,.fi-crop:before,.fi-crown:before,.fi-css3:before,.fi-database:before,.fi-die-five:before,.fi-die-four:before,.fi-die-one:before,.fi-die-six:before,.fi-die-three:before,.fi-die-two:before,.fi-dislike:before,.fi-dollar-bill:before,.fi-dollar:before,.fi-download:before,.fi-eject:before,.fi-elevator:before,.fi-euro:before,.fi-eye:before,.fi-fast-forward:before,.fi-female-symbol:before,.fi-female:before,.fi-filter:before,.fi-first-aid:before,.fi-flag:before,.fi-folder-add:before,.fi-folder-lock:before,.fi-folder:before,.fi-foot:before,.fi-foundation:before,.fi-graph-bar:before,.fi-graph-horizontal:before,.fi-graph-pie:before,.fi-graph-trend:before,.fi-guide-dog:before,.fi-hearing-aid:before,.fi-heart:before,.fi-home:before,.fi-html5:before,.fi-indent-less:before,.fi-indent-more:before,.fi-info:before,.fi-italic:before,.fi-key:before,.fi-laptop:before,.fi-layout:before,.fi-lightbulb:before,.fi-like:before,.fi-link:before,.fi-list-bullet:before,.fi-list-number:before,.fi-list-thumbnails:before,.fi-list:before,.fi-lock:before,.fi-loop:before,.fi-magnifying-glass:before,.fi-mail:before,.fi-male-female:before,.fi-male-symbol:before,.fi-male:before,.fi-map:before,.fi-marker:before,.fi-megaphone:before,.fi-microphone:before,.fi-minus-circle:before,.fi-minus:before,.fi-mobile-signal:before,.fi-mobile:before,.fi-monitor:before,.fi-mountains:before,.fi-music:before,.fi-next:before,.fi-no-dogs:before,.fi-no-smoking:before,.fi-page-add:before,.fi-page-copy:before,.fi-page-csv:before,.fi-page-delete:before,.fi-page-doc:before,.fi-page-edit:before,.fi-page-export-csv:before,.fi-page-export-doc:before,.fi-page-export-pdf:before,.fi-page-export:before,.fi-page-filled:before,.fi-page-multiple:before,.fi-page-pdf:before,.fi-page-remove:before,.fi-page-search:before,.fi-page:before,.fi-paint-bucket:before,.fi-paperclip:before,.fi-pause:before,.fi-paw:before,.fi-paypal:before,.fi-pencil:before,.fi-photo:before,.fi-play-circle:before,.fi-play-video:before,.fi-play:before,.fi-plus:before,.fi-pound:before,.fi-power:before,.fi-previous:before,.fi-price-tag:before,.fi-pricetag-multiple:before,.fi-print:before,.fi-prohibited:before,.fi-projection-screen:before,.fi-puzzle:before,.fi-quote:before,.fi-record:before,.fi-refresh:before,.fi-results-demographics:before,.fi-results:before,.fi-rewind-ten:before,.fi-rewind:before,.fi-rss:before,.fi-safety-cone:before,.fi-save:before,.fi-share:before,.fi-sheriff-badge:before,.fi-shield:before,.fi-shopping-bag:before,.fi-shopping-cart:before,.fi-shuffle:before,.fi-skull:before,.fi-social-500px:before,.fi-social-adobe:before,.fi-social-amazon:before,.fi-social-android:before,.fi-social-apple:before,.fi-social-behance:before,.fi-social-bing:before,.fi-social-blogger:before,.fi-social-delicious:before,.fi-social-designer-news:before,.fi-social-deviant-art:before,.fi-social-digg:before,.fi-social-dribbble:before,.fi-social-drive:before,.fi-social-dropbox:before,.fi-social-evernote:before,.fi-social-facebook:before,.fi-social-flickr:before,.fi-social-forrst:before,.fi-social-foursquare:before,.fi-social-game-center:before,.fi-social-github:before,.fi-social-google-plus:before,.fi-social-hacker-news:before,.fi-social-hi5:before,.fi-social-instagram:before,.fi-social-joomla:before,.fi-social-lastfm:before,.fi-social-linkedin:before,.fi-social-medium:before,.fi-social-myspace:before,.fi-social-orkut:before,.fi-social-path:before,.fi-social-picasa:before,.fi-social-pinterest:before,.fi-social-rdio:before,.fi-social-reddit:before,.fi-social-skillshare:before,.fi-social-skype:before,.fi-social-smashing-mag:before,.fi-social-snapchat:before,.fi-social-spotify:before,.fi-social-squidoo:before,.fi-social-stack-overflow:before,.fi-social-steam:before,.fi-social-stumbleupon:before,.fi-social-treehouse:before,.fi-social-tumblr:before,.fi-social-twitter:before,.fi-social-vimeo:before,.fi-social-windows:before,.fi-social-xbox:before,.fi-social-yahoo:before,.fi-social-yelp:before,.fi-social-youtube:before,.fi-social-zerply:before,.fi-social-zurb:before,.fi-sound:before,.fi-star:before,.fi-stop:before,.fi-strikethrough:before,.fi-subscript:before,.fi-superscript:before,.fi-tablet-landscape:before,.fi-tablet-portrait:before,.fi-target-two:before,.fi-target:before,.fi-telephone-accessible:before,.fi-telephone:before,.fi-text-color:before,.fi-thumbnails:before,.fi-ticket:before,.fi-torso-business:before,.fi-torso-female:before,.fi-torso:before,.fi-torsos-all-female:before,.fi-torsos-all:before,.fi-torsos-female-male:before,.fi-torsos-male-female:before,.fi-torsos:before,.fi-trash:before,.fi-trees:before,.fi-trophy:before,.fi-underline:before,.fi-universal-access:before,.fi-unlink:before,.fi-unlock:before,.fi-upload-cloud:before,.fi-upload:before,.fi-usb:before,.fi-video:before,.fi-volume-none:before,.fi-volume-strike:before,.fi-volume:before,.fi-web:before,.fi-wheelchair:before,.fi-widget:before,.fi-wrench:before,.fi-x-circle:before,.fi-x:before,.fi-yen:before,.fi-zoom-in:before,.fi-zoom-out:before{font-family:foundation-icons;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;line-height:1;-webkit-font-smoothing:antialiased;display:inline-block;text-decoration:inherit}.fi-address-book:before{content:"\f100"}.fi-alert:before{content:"\f101"}.fi-align-center:before{content:"\f102"}.fi-align-justify:before{content:"\f103"}.fi-align-left:before{content:"\f104"}.fi-align-right:before{content:"\f105"}.fi-anchor:before{content:"\f106"}.fi-annotate:before{content:"\f107"}.fi-archive:before{content:"\f108"}.fi-arrow-down:before{content:"\f109"}.fi-arrow-left:before{content:"\f10a"}.fi-arrow-right:before{content:"\f10b"}.fi-arrow-up:before{content:"\f10c"}.fi-arrows-compress:before{content:"\f10d"}.fi-arrows-expand:before{content:"\f10e"}.fi-arrows-in:before{content:"\f10f"}.fi-arrows-out:before{content:"\f110"}.fi-asl:before{content:"\f111"}.fi-asterisk:before{content:"\f112"}.fi-at-sign:before{content:"\f113"}.fi-background-color:before{content:"\f114"}.fi-battery-empty:before{content:"\f115"}.fi-battery-full:before{content:"\f116"}.fi-battery-half:before{content:"\f117"}.fi-bitcoin-circle:before{content:"\f118"}.fi-bitcoin:before{content:"\f119"}.fi-blind:before{content:"\f11a"}.fi-bluetooth:before{content:"\f11b"}.fi-bold:before{content:"\f11c"}.fi-book-bookmark:before{content:"\f11d"}.fi-book:before{content:"\f11e"}.fi-bookmark:before{content:"\f11f"}.fi-braille:before{content:"\f120"}.fi-burst-new:before{content:"\f121"}.fi-burst-sale:before{content:"\f122"}.fi-burst:before{content:"\f123"}.fi-calendar:before{content:"\f124"}.fi-camera:before{content:"\f125"}.fi-check:before{content:"\f126"}.fi-checkbox:before{content:"\f127"}.fi-clipboard-notes:before{content:"\f128"}.fi-clipboard-pencil:before{content:"\f129"}.fi-clipboard:before{content:"\f12a"}.fi-clock:before{content:"\f12b"}.fi-closed-caption:before{content:"\f12c"}.fi-cloud:before{content:"\f12d"}.fi-comment-minus:before{content:"\f12e"}.fi-comment-quotes:before{content:"\f12f"}.fi-comment-video:before{content:"\f130"}.fi-comment:before{content:"\f131"}.fi-comments:before{content:"\f132"}.fi-compass:before{content:"\f133"}.fi-contrast:before{content:"\f134"}.fi-credit-card:before{content:"\f135"}.fi-crop:before{content:"\f136"}.fi-crown:before{content:"\f137"}.fi-css3:before{content:"\f138"}.fi-database:before{content:"\f139"}.fi-die-five:before{content:"\f13a"}.fi-die-four:before{content:"\f13b"}.fi-die-one:before{content:"\f13c"}.fi-die-six:before{content:"\f13d"}.fi-die-three:before{content:"\f13e"}.fi-die-two:before{content:"\f13f"}.fi-dislike:before{content:"\f140"}.fi-dollar-bill:before{content:"\f141"}.fi-dollar:before{content:"\f142"}.fi-download:before{content:"\f143"}.fi-eject:before{content:"\f144"}.fi-elevator:before{content:"\f145"}.fi-euro:before{content:"\f146"}.fi-eye:before{content:"\f147"}.fi-fast-forward:before{content:"\f148"}.fi-female-symbol:before{content:"\f149"}.fi-female:before{content:"\f14a"}.fi-filter:before{content:"\f14b"}.fi-first-aid:before{content:"\f14c"}.fi-flag:before{content:"\f14d"}.fi-folder-add:before{content:"\f14e"}.fi-folder-lock:before{content:"\f14f"}.fi-folder:before{content:"\f150"}.fi-foot:before{content:"\f151"}.fi-foundation:before{content:"\f152"}.fi-graph-bar:before{content:"\f153"}.fi-graph-horizontal:before{content:"\f154"}.fi-graph-pie:before{content:"\f155"}.fi-graph-trend:before{content:"\f156"}.fi-guide-dog:before{content:"\f157"}.fi-hearing-aid:before{content:"\f158"}.fi-heart:before{content:"\f159"}.fi-home:before{content:"\f15a"}.fi-html5:before{content:"\f15b"}.fi-indent-less:before{content:"\f15c"}.fi-indent-more:before{content:"\f15d"}.fi-info:before{content:"\f15e"}.fi-italic:before{content:"\f15f"}.fi-key:before{content:"\f160"}.fi-laptop:before{content:"\f161"}.fi-layout:before{content:"\f162"}.fi-lightbulb:before{content:"\f163"}.fi-like:before{content:"\f164"}.fi-link:before{content:"\f165"}.fi-list-bullet:before{content:"\f166"}.fi-list-number:before{content:"\f167"}.fi-list-thumbnails:before{content:"\f168"}.fi-list:before{content:"\f169"}.fi-lock:before{content:"\f16a"}.fi-loop:before{content:"\f16b"}.fi-magnifying-glass:before{content:"\f16c"}.fi-mail:before{content:"\f16d"}.fi-male-female:before{content:"\f16e"}.fi-male-symbol:before{content:"\f16f"}.fi-male:before{content:"\f170"}.fi-map:before{content:"\f171"}.fi-marker:before{content:"\f172"}.fi-megaphone:before{content:"\f173"}.fi-microphone:before{content:"\f174"}.fi-minus-circle:before{content:"\f175"}.fi-minus:before{content:"\f176"}.fi-mobile-signal:before{content:"\f177"}.fi-mobile:before{content:"\f178"}.fi-monitor:before{content:"\f179"}.fi-mountains:before{content:"\f17a"}.fi-music:before{content:"\f17b"}.fi-next:before{content:"\f17c"}.fi-no-dogs:before{content:"\f17d"}.fi-no-smoking:before{content:"\f17e"}.fi-page-add:before{content:"\f17f"}.fi-page-copy:before{content:"\f180"}.fi-page-csv:before{content:"\f181"}.fi-page-delete:before{content:"\f182"}.fi-page-doc:before{content:"\f183"}.fi-page-edit:before{content:"\f184"}.fi-page-export-csv:before{content:"\f185"}.fi-page-export-doc:before{content:"\f186"}.fi-page-export-pdf:before{content:"\f187"}.fi-page-export:before{content:"\f188"}.fi-page-filled:before{content:"\f189"}.fi-page-multiple:before{content:"\f18a"}.fi-page-pdf:before{content:"\f18b"}.fi-page-remove:before{content:"\f18c"}.fi-page-search:before{content:"\f18d"}.fi-page:before{content:"\f18e"}.fi-paint-bucket:before{content:"\f18f"}.fi-paperclip:before{content:"\f190"}.fi-pause:before{content:"\f191"}.fi-paw:before{content:"\f192"}.fi-paypal:before{content:"\f193"}.fi-pencil:before{content:"\f194"}.fi-photo:before{content:"\f195"}.fi-play-circle:before{content:"\f196"}.fi-play-video:before{content:"\f197"}.fi-play:before{content:"\f198"}.fi-plus:before{content:"\f199"}.fi-pound:before{content:"\f19a"}.fi-power:before{content:"\f19b"}.fi-previous:before{content:"\f19c"}.fi-price-tag:before{content:"\f19d"}.fi-pricetag-multiple:before{content:"\f19e"}.fi-print:before{content:"\f19f"}.fi-prohibited:before{content:"\f1a0"}.fi-projection-screen:before{content:"\f1a1"}.fi-puzzle:before{content:"\f1a2"}.fi-quote:before{content:"\f1a3"}.fi-record:before{content:"\f1a4"}.fi-refresh:before{content:"\f1a5"}.fi-results-demographics:before{content:"\f1a6"}.fi-results:before{content:"\f1a7"}.fi-rewind-ten:before{content:"\f1a8"}.fi-rewind:before{content:"\f1a9"}.fi-rss:before{content:"\f1aa"}.fi-safety-cone:before{content:"\f1ab"}.fi-save:before{content:"\f1ac"}.fi-share:before{content:"\f1ad"}.fi-sheriff-badge:before{content:"\f1ae"}.fi-shield:before{content:"\f1af"}.fi-shopping-bag:before{content:"\f1b0"}.fi-shopping-cart:before{content:"\f1b1"}.fi-shuffle:before{content:"\f1b2"}.fi-skull:before{content:"\f1b3"}.fi-social-500px:before{content:"\f1b4"}.fi-social-adobe:before{content:"\f1b5"}.fi-social-amazon:before{content:"\f1b6"}.fi-social-android:before{content:"\f1b7"}.fi-social-apple:before{content:"\f1b8"}.fi-social-behance:before{content:"\f1b9"}.fi-social-bing:before{content:"\f1ba"}.fi-social-blogger:before{content:"\f1bb"}.fi-social-delicious:before{content:"\f1bc"}.fi-social-designer-news:before{content:"\f1bd"}.fi-social-deviant-art:before{content:"\f1be"}.fi-social-digg:before{content:"\f1bf"}.fi-social-dribbble:before{content:"\f1c0"}.fi-social-drive:before{content:"\f1c1"}.fi-social-dropbox:before{content:"\f1c2"}.fi-social-evernote:before{content:"\f1c3"}.fi-social-facebook:before{content:"\f1c4"}.fi-social-flickr:before{content:"\f1c5"}.fi-social-forrst:before{content:"\f1c6"}.fi-social-foursquare:before{content:"\f1c7"}.fi-social-game-center:before{content:"\f1c8"}.fi-social-github:before{content:"\f1c9"}.fi-social-google-plus:before{content:"\f1ca"}.fi-social-hacker-news:before{content:"\f1cb"}.fi-social-hi5:before{content:"\f1cc"}.fi-social-instagram:before{content:"\f1cd"}.fi-social-joomla:before{content:"\f1ce"}.fi-social-lastfm:before{content:"\f1cf"}.fi-social-linkedin:before{content:"\f1d0"}.fi-social-medium:before{content:"\f1d1"}.fi-social-myspace:before{content:"\f1d2"}.fi-social-orkut:before{content:"\f1d3"}.fi-social-path:before{content:"\f1d4"}.fi-social-picasa:before{content:"\f1d5"}.fi-social-pinterest:before{content:"\f1d6"}.fi-social-rdio:before{content:"\f1d7"}.fi-social-reddit:before{content:"\f1d8"}.fi-social-skillshare:before{content:"\f1d9"}.fi-social-skype:before{content:"\f1da"}.fi-social-smashing-mag:before{content:"\f1db"}.fi-social-snapchat:before{content:"\f1dc"}.fi-social-spotify:before{content:"\f1dd"}.fi-social-squidoo:before{content:"\f1de"}.fi-social-stack-overflow:before{content:"\f1df"}.fi-social-steam:before{content:"\f1e0"}.fi-social-stumbleupon:before{content:"\f1e1"}.fi-social-treehouse:before{content:"\f1e2"}.fi-social-tumblr:before{content:"\f1e3"}.fi-social-twitter:before{content:"\f1e4"}.fi-social-vimeo:before{content:"\f1e5"}.fi-social-windows:before{content:"\f1e6"}.fi-social-xbox:before{content:"\f1e7"}.fi-social-yahoo:before{content:"\f1e8"}.fi-social-yelp:before{content:"\f1e9"}.fi-social-youtube:before{content:"\f1ea"}.fi-social-zerply:before{content:"\f1eb"}.fi-social-zurb:before{content:"\f1ec"}.fi-sound:before{content:"\f1ed"}.fi-star:before{content:"\f1ee"}.fi-stop:before{content:"\f1ef"}.fi-strikethrough:before{content:"\f1f0"}.fi-subscript:before{content:"\f1f1"}.fi-superscript:before{content:"\f1f2"}.fi-tablet-landscape:before{content:"\f1f3"}.fi-tablet-portrait:before{content:"\f1f4"}.fi-target-two:before{content:"\f1f5"}.fi-target:before{content:"\f1f6"}.fi-telephone-accessible:before{content:"\f1f7"}.fi-telephone:before{content:"\f1f8"}.fi-text-color:before{content:"\f1f9"}.fi-thumbnails:before{content:"\f1fa"}.fi-ticket:before{content:"\f1fb"}.fi-torso-business:before{content:"\f1fc"}.fi-torso-female:before{content:"\f1fd"}.fi-torso:before{content:"\f1fe"}.fi-torsos-all-female:before{content:"\f1ff"}.fi-torsos-all:before{content:"\f200"}.fi-torsos-female-male:before{content:"\f201"}.fi-torsos-male-female:before{content:"\f202"}.fi-torsos:before{content:"\f203"}.fi-trash:before{content:"\f204"}.fi-trees:before{content:"\f205"}.fi-trophy:before{content:"\f206"}.fi-underline:before{content:"\f207"}.fi-universal-access:before{content:"\f208"}.fi-unlink:before{content:"\f209"}.fi-unlock:before{content:"\f20a"}.fi-upload-cloud:before{content:"\f20b"}.fi-upload:before{content:"\f20c"}.fi-usb:before{content:"\f20d"}.fi-video:before{content:"\f20e"}.fi-volume-none:before{content:"\f20f"}.fi-volume-strike:before{content:"\f210"}.fi-volume:before{content:"\f211"}.fi-web:before{content:"\f212"}.fi-wheelchair:before{content:"\f213"}.fi-widget:before{content:"\f214"}.fi-wrench:before{content:"\f215"}.fi-x-circle:before{content:"\f216"}.fi-x:before{content:"\f217"}.fi-yen:before{content:"\f218"}.fi-zoom-in:before{content:"\f219"}.fi-zoom-out:before{content:"\f21a"}
\ No newline at end of file
diff --git a/data/static_src/css/pythonz.css b/data/static_src/css/pythonz.css
deleted file mode 100644
index 76461833..00000000
--- a/data/static_src/css/pythonz.css
+++ /dev/null
@@ -1 +0,0 @@
-.block{display:inline-block}.of__hidden{overflow:hidden}.cl__blue{color:#3776ab}.cl__yellow{color:#ffd343}.cl__green{color:#82b043}.cl__gray{color:#888}.pad__t_min{padding-top:10px}.pad__t_mid{padding-top:20px}.marg__t_min{margin-top:10px}.marg__t_mid{margin-top:20px}.marg__t_max{margin-top:40px}.marg__b_min{margin-bottom:10px}.marg__b_mid{margin-bottom:20px}.marg__b_max{margin-bottom:40px}.marg__l_min{margin-left:10px}.marg__l_mid{margin-left:20px}.marg__r_min{margin-right:10px}.marg__r_mid{margin-right:20px}.footer{border-bottom:0;margin-top:40px;padding-top:20px;font-size:12px}.footer .menu li{display:inline-block;margin-right:10px}.panel{font-size:14px}.panel .panel-title{font-size:16px}.panel .panel-body{color:#4d4d4d}.panel .panel-body ul,.panel .panel-body ol{padding-left:inherit;padding-right:inherit}.page-header{margin-top:0}.navbar{margin-bottom:0}.navbar input{margin-top:4px;height:30px;max-width:180px;font-size:15px}.navbar .navbar-brand img{margin:5px}.control-label{font-weight:normal;color:#666}.well a{color:#666;text-decoration:underline}.breadcrumb{font-size:12px;padding:0;margin:0}.b-forgetmenot_size_l{height:28px!important}html,body{padding-top:20px}header h1{font-family:Belleza,sans-serif;font-size:45px}header a:hover{text-decoration:none}h1,h2,h3,h4,h5,h6{font-family:'PT Serif',serif}h6{padding-bottom:10px;font-weight:bold;margin-bottom:20px;color:#4f90c6;border-bottom:1px #3776ab dashed}input[type=checkbox]{display:inline-block;width:10px;float:right}#page_controls{width:100%;text-align:center}#page_controls form{display:inline}#bar__most_voted{background-color:#fbfbfb;border-radius:10px}nav .submenu{border-left:4px #82b043 solid}.body{background-color:#fff;padding-top:40px;padding-bottom:40px}.section{margin-bottom:40px}.mod__has_tooltip{cursor:help}.icon_entity{font-size:40px;margin-right:20px;float:left;color:#3776ab}.form_table td{vertical-align:top}.listing_item.small{min-height:80px}.float_panel{min-width:250px}.userlist_item{margin:2px}.gist{overflow:auto}.gist pre{font-size:12px!important}.discussions{list-style-type:none;padding:0}.discussions .well{background-color:#fff}.discussions .discussion{padding:10px}.discussions .discussion:hover{background-color:#f9fcf6}.vacancies .label{font-family:Verdana,Arial,helvetica,sans-serif}.tags_box{font-family:Verdana,Arial,helvetica,sans-serif}.tags_box .big .title{font-size:20px;min-width:250px;max-width:250px}.tags_box .big .categories_box{margin-bottom:40px}.tags_box .big .list_entry{margin-right:20px}.tags_box ul{list-style-type:none;display:inline;padding:0;vertical-align:middle}.tags_box .categories_box{margin-bottom:10px;font-size:14px}.tags_box .btn_remove{margin-right:10px}.tags_box form{display:inline}.tags_box .title{color:#888;min-width:55px;max-width:55px;font-size:10px;display:inline;float:left;margin-right:10px}.tags_box .choice{margin-right:10px;font-size:12px;color:#3776ab;cursor:pointer}.tags_box .editor{margin:10px 0 10px 63px;padding-bottom:10px;border-bottom:1px solid #eee}.tags_box li{margin:0;padding:0;display:inline}.zen .eng{line-height:1.3em;font-size:10px}.zen h2{line-height:1.2em}.zen .small{font-size:10px;text-align:right}.features .features_row div{min-width:100px;padding:10px}.features i{color:#808080;font-size:28px}.features a{color:#d9d9d9}.features i:hover,.features a:hover{color:#3776ab;text-decoration:none}.cover_img .icon_entity{padding:20px;color:#bbb;min-width:100px}.cover_img a{text-align:center}.faded{opacity:.1}.faded:hover{opacity:1}.listing_item{padding:10px;min-width:350px;max-width:350px;min-height:150px;max-height:150px;float:left;margin-right:20px;margin-top:20px;border-right:1px solid #eee}.listing_item img{margin-right:20px;float:left}.listing_item sup{color:#888}.listing_item .rating{position:absolute;margin-left:10px}.listing_item .description{overflow:hidden}.listing_item .description .small{margin-top:10px;font-size:10px}
\ No newline at end of file
diff --git a/data/static_src/js/pythonz.min.js b/data/static_src/js/pythonz.min.js
deleted file mode 100644
index 26459be3..00000000
--- a/data/static_src/js/pythonz.min.js
+++ /dev/null
@@ -1 +0,0 @@
-pythonz={bootstrap:function(){xross.automate();$(function(){pythonz.mark_user();sitecats.bootstrap();sitecats.make_cloud("tags_box");$(".mod__has_tooltip").tooltip()})},mark_user:function(){$(".py_user").each(function(e,t){var n=t.innerHTML.replace(/\[u:(\d+):\s*([^\]\s]+)\s*\]/g,'$2 ');$(t).html(n)})},Reference:{RULE_PYVERSION_ADDED:[/\+py([\w\.]+)/g,'$1
'],RULE_PYVERSION_REMOVED:[/-py([\w\.]+)/g,'$1
'],RULE_LITERAL:[/'([^']+)'/g,'$1 '],RULE_UNDERMETHOD:[/(__[^\s]+__)/g,"$1 "],RULE_BASE_TYPES:[/([^\w/])(bool|callable|dict|False|int|iterable|iterator|list|None|object|set|str|True|tuple|unicode)([^\w])/g,"$1$2 $3"],RULE_EXCEPTIONS:[/([^\w])(AttributeError|ImportError|IndexError|KeyError|NotImplementedError|RuntimeError|StopIteration|SyntaxError|SystemError|TypeError|UnboundLocalError|ValueError)([^\w])/g,'$1$2
$3'],RULE_EMDASH:[/\s+-\s+/g," — "],decorate_description:function(e){this.decorate_area(e,[this.RULE_PYVERSION_REMOVED,this.RULE_PYVERSION_ADDED,this.RULE_BASE_TYPES,this.RULE_EXCEPTIONS,this.RULE_EMDASH])},decorate_func_result:function(e){this.decorate_area(e,[this.RULE_PYVERSION_REMOVED,this.RULE_PYVERSION_ADDED,this.RULE_BASE_TYPES,this.RULE_EMDASH])},decorate_func_params:function(e){var t=function(e,t,n){t=t.replace(/([^=]+)(=.+)/g,'$1$2 ');return' '+t+" "+n};this.decorate_area(e,[[/([^>\s]+)(\s--)/g,t],[/--/g,":"],this.RULE_PYVERSION_REMOVED,this.RULE_PYVERSION_ADDED,this.RULE_LITERAL,this.RULE_UNDERMETHOD,this.RULE_BASE_TYPES,this.RULE_EXCEPTIONS,this.RULE_EMDASH])},decorate_area:function(e,t){var n=$("#"+e),r=n.html();if(r!==undefined){$.each(t,function(e,t){r=r.replace(t[0],t[1])})}n.html(r)}},Map:function(e,t){var n=this,r=$("#"+e),i=t;this.get_bounds_for_coords=function(e){return ymaps.util.bounds.getCenterAndZoom(e,[r.width(),r.height()])};this.get_placemarks_from_map_objects=function(e){var t=[],n=0;if(e===undefined){e=i}$.each(e,function(e,r){var i=r.coords,s=r.title,o=r.descr,u=r.link;t[n]=new ymaps.Placemark(i,{balloonContentHeader:s,balloonContentBody:o,balloonContentFooter:u,clusterCaption:s,place_id:e},{hideIconOnBalloonOpen:false,preset:"islands#darkBlueCircleDotIcon"});n++});return t};this.get_clusterer=function(){var e=n.get_placemarks_from_map_objects(),t=new ymaps.Clusterer({preset:"islands#darkBlueClusterIcons",clusterDisableClickZoom:true,clusterBalloonPanelMaxMapArea:0,clusterBalloonContentLayoutWidth:250,clusterBalloonContentLayoutHeight:100,clusterBalloonLeftColumnWidth:100});t.add(e);return t};this.init_map=function(){ymaps.ready(function(){var e=n.get_clusterer(),t=n.get_bounds_for_coords(e.getBounds());$.extend(t,{controls:["zoomControl"]});var i=new ymaps.Map(r.attr("id"),t);i.geoObjects.add(e)})};n.init_map()}};pythonz.bootstrap()
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..1975578c
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,7 @@
+version: '3.8'
+services:
+ web:
+ build: .
+ command: uv run pythonz runserver 0.0.0.0:8000
+ ports:
+ - 8000:8000
diff --git a/manage.py b/manage.py
deleted file mode 100755
index 86eb56a8..00000000
--- a/manage.py
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/usr/bin/env python
-import os
-import sys
-
-
-if __name__ == '__main__':
- # Для правильного импорта модулей добавим пару путей в список поиска:
- PROJECT_PATH = os.path.realpath(os.path.dirname(__file__))
- sys.path = [os.path.dirname(PROJECT_PATH), PROJECT_PATH] + sys.path
-
- os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings.prod')
- from django.core.management import execute_from_command_line
- execute_from_command_line(sys.argv)
diff --git a/manage_dev.py b/manage_dev.py
deleted file mode 100755
index 881ea6a6..00000000
--- a/manage_dev.py
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/usr/bin/env python
-import os
-import sys
-
-
-if __name__ == '__main__':
- # Для правильного импорта модулей добавим пару путей в список поиска:
- PROJECT_PATH = os.path.realpath(os.path.dirname(__file__))
- sys.path = [os.path.dirname(PROJECT_PATH), PROJECT_PATH] + sys.path
-
- os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings.dev')
- from django.core.management import execute_from_command_line
- execute_from_command_line(sys.argv)
diff --git a/nginx_pages/error.html b/nginx_pages/error.html
deleted file mode 100644
index 369cd2b3..00000000
--- a/nginx_pages/error.html
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
- PYTHONZ. Ошибка вышла...
-
-
-
- PYTHONZ. Ошибка вышла…
- В данный момент pythonz.net испытывает некоторые трудности c тем, чтобы донести себя до вас.
- Скорее всего, компетентные органы уже занимаются этим вопросом, и вскоре всё вернётся на круги своя.
- Всё будет хорошо.
-
-
-
-
\ No newline at end of file
diff --git a/nginx_pages/maintenance.html b/nginx_pages/maintenance.html
deleted file mode 100644
index 61fcb4e8..00000000
--- a/nginx_pages/maintenance.html
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
- PYTHONZ. Идут работы
-
-
-
- PYTHONZ. Идут работы
- Как раз сейчас на pythonz.net идут технические работы.
- Через некоторое время сайт снова будет с вами.
- Возвращайтесь.
-
-
-
-
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..25c0ad6a
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,122 @@
+[project]
+name = "pythonz"
+dynamic = ["version"]
+description = "Source code for pythonz.net"
+authors = [
+ { name = "Igor Starikov", email = "idlesign@yandex.ru" }
+]
+readme = "README.md"
+license = "GPL-3.0-only"
+license-files = ["LICENSE"]
+requires-python = "==3.12.*"
+dependencies = [
+ "awesome-slugify~=1.6.5",
+ "beautifulsoup4~=4.13",
+ "bleach~=6.2.0",
+ "django~=5.2.4",
+ "django-admirarchy~=1.2.2",
+ "django-etc~=1.4.0",
+ "django-robots~=6.1",
+ "django-simple-history~=3.10.1",
+ "django-siteajax~=1.0.0",
+ "django-siteblocks~=1.2.1",
+ "django-sitecats~=1.2.2",
+ "django-siteflags~=1.3.0",
+ "django-siteforms~=1.2.0",
+ "django-sitegate~=1.3.3",
+ "django-sitemessage~=1.4.0",
+ "django-sitemetrics~=1.2.0",
+ "django-siteprefs~=1.2.3",
+ "django-sitetree~=1.18.0",
+ "django-yaturbo~=1.0.1",
+ "envbox~=1.3.0",
+ "feedparser~=6.0.11",
+ "icalendar-light~=1.0.0",
+ "lxml~=6.0.0",
+ "pillow~=11.3.0",
+ "psycopg~=3.2.9",
+ "pytelegrambotapi~=4.9.0",
+ "pytz~=2025.2",
+ "regex~=2024.11.6",
+ "requests[socks]~=2.32.4",
+ "sentry-sdk~=2.32.0",
+ "twitter~=1.19.6",
+ "uwsgi~=2.0.30",
+ "uwsgiconf[cli]~=2.0.0",
+]
+
+[project.urls]
+Homepage = "https://github.com/idlesign/pythonz"
+
+[project.scripts]
+pythonz = "pythonz.manage:main"
+
+[dependency-groups]
+dev = [
+ {include-group = "linters"},
+ {include-group = "tests"},
+ {include-group = "runtime"},
+]
+linters = [
+]
+tests = [
+ "pytest",
+ "pytest-djangoapp>=1.3.0",
+ "pytest-responsemock",
+ "freezegun",
+]
+runtime = [
+ "django-debug-toolbar~=5.2.0",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.version]
+path = "pythonz/__init__.py"
+
+[tool.hatch.build.targets.wheel]
+packages = ["pythonz/"]
+
+[tool.hatch.build.targets.sdist]
+packages = ["pythonz/"]
+
+[tool.pytest.ini_options]
+testpaths = [
+ "tests",
+]
+markers = [
+ "slow: long running tests",
+]
+addopts = "--strict-markers"
+
+[tool.coverage.run]
+source = [
+ "pythonz/",
+]
+omit = [
+ "pythonz/apps/migrations/*",
+]
+
+[tool.coverage.report]
+fail_under = 90.00
+exclude_also = [
+ "raise NotImplementedError",
+ "if TYPE_CHECKING:",
+]
+
+[tool.tox]
+skip_missing_interpreters = true
+env_list = [
+ "py310",
+ "py311",
+ "py312",
+ "py313",
+]
+
+[tool.tox.env_run_base]
+dependency_groups = ["tests"]
+commands = [
+ ["pytest", { replace = "posargs", default = ["tests"], extend = true }],
+]
diff --git a/pythonz/__init__.py b/pythonz/__init__.py
new file mode 100644
index 00000000..f84bdd53
--- /dev/null
+++ b/pythonz/__init__.py
@@ -0,0 +1,4 @@
+
+
+VERSION = '1.0.0'
+"""Application version."""
diff --git a/pythonz/apps/__init__.py b/pythonz/apps/__init__.py
new file mode 100644
index 00000000..cdbcf3d0
--- /dev/null
+++ b/pythonz/apps/__init__.py
@@ -0,0 +1,16 @@
+from json import JSONEncoder
+
+
+def patch_json_encoder():
+ """Модифицирует базовый json-кодировщик, для поддержки метода
+ to_json пользовательских типов.
+
+ """
+ def json_encoder_default(self, obj):
+ return getattr(obj.__class__, 'to_json', json_encoder_default.default_)(obj)
+
+ json_encoder_default.default_ = JSONEncoder().default
+ JSONEncoder.default = json_encoder_default
+
+
+patch_json_encoder()
diff --git a/pythonz/apps/admin.py b/pythonz/apps/admin.py
new file mode 100644
index 00000000..f9de4809
--- /dev/null
+++ b/pythonz/apps/admin.py
@@ -0,0 +1,309 @@
+from json import loads
+
+from admirarchy.toolbox import HierarchicalModelAdmin
+from django import forms
+from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
+from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
+from django.contrib.auth.forms import UserCreationForm as BaseUserCreationForm
+from django.contrib.contenttypes.admin import GenericTabularInline
+from django.db import models
+from django.db.models import QuerySet
+from django.http import HttpRequest
+from etc.admin import CustomModelPage
+from simple_history.admin import SimpleHistoryAdmin
+
+from .forms.forms import BookForm
+from .integration.partners import get_partners_choices
+from .models import (
+ PEP,
+ App,
+ Article,
+ Book,
+ Community,
+ Discussion,
+ Event,
+ ExternalResource,
+ PartnerLink,
+ Person,
+ Place,
+ Reference,
+ ReferenceMissing,
+ Summary,
+ User,
+ Vacancy,
+ Version,
+ Video,
+)
+
+
+class BookLinksImportPage(CustomModelPage):
+ """Обрабатывает данные о партнёрских ссылках, загружаемые пакетно."""
+
+ title = 'Импорт ссылок на книги'
+
+ data = models.FileField('Данные для импорта')
+
+ def save(self):
+ links = Book.partner_links_enrich(loads(self.data.read()))
+ self.bound_admin.message_success(self.bound_request, f'Добавлено ссылок: {len(links)}.')
+
+
+BookLinksImportPage.register()
+
+
+def get_inline(model: type[models.Model], field_name: str) -> type:
+ """Возвращает класс встраиваемого редактора для использования
+ в inlines в случаях полей многие-ко-многим.
+
+ :param model:
+ :param field_name:
+
+ """
+ inline_cls = type(f'{model.__name__.capitalize()}Inline', (admin.TabularInline,), {
+ 'model': getattr(model, field_name).through,
+ 'extra': 0,
+ })
+ return inline_cls
+
+
+##################################################################################
+# Делаем возможным редактировение пользователей (модель изменена нами) в административной части.
+# Пользователи отобразятся в разделе текущего приложения.
+# Взято из http://stackoverflow.com/a/17496836/308265
+
+
+class UserChangeForm(BaseUserChangeForm):
+
+ class Meta(BaseUserChangeForm.Meta):
+ model = User
+
+
+class UserCreationForm(BaseUserCreationForm):
+
+ class Meta(BaseUserCreationForm.Meta):
+ model = User
+
+ def clean_username(self):
+ username = self.cleaned_data['username']
+
+ try:
+ User.objects.get(username=username)
+
+ except User.DoesNotExist:
+ return username
+
+ raise forms.ValidationError(self.error_messages['duplicate_username'])
+
+
+@admin.register(User)
+class UserAdmin(BaseUserAdmin):
+
+ form = UserChangeForm
+ add_form = UserCreationForm
+ list_display = BaseUserAdmin.list_display + ('karma', 'time_created', 'url', 'is_active',)
+
+ fieldsets = BaseUserAdmin.fieldsets + ((None, {'fields': (
+ 'karma',
+ 'profile_public',
+ 'timezone',
+ 'url',
+ 'email_public'
+ )}),)
+
+##################################################################################
+# Партнёрские ссылки.
+#
+
+
+class PartnerLinkForm(forms.ModelForm):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ field = self.fields['partner_alias']
+ field.widget = forms.fields.Select(choices=get_partners_choices())
+
+ class Meta:
+ model = PartnerLink
+ fields = '__all__'
+
+
+class PartnerLinkInline(GenericTabularInline):
+
+ model = PartnerLink
+ form = PartnerLinkForm
+ extra = 0
+
+
+@admin.register(PartnerLink)
+class PartnerLinkAdmin(admin.ModelAdmin):
+
+ list_display = ('linked_object', 'partner_alias', 'description', 'url')
+ list_filter = ['partner_alias']
+
+
+##################################################################################
+
+@admin.register(Summary)
+class SummaryAdmin(admin.ModelAdmin):
+
+ list_display = ('time_created',)
+ list_filter = ['time_created']
+
+
+@admin.register(ExternalResource)
+class ExternalResourceAdmin(admin.ModelAdmin):
+
+ list_display = ('title', 'url', 'src_alias', 'realm_name', 'time_created')
+ list_filter = ['src_alias', 'realm_name']
+ search_fields = ['title', 'description']
+
+
+@admin.register(Place)
+class PlaceAdmin(admin.ModelAdmin):
+
+ list_display = ('geo_title', 'title', 'status', 'time_created', 'time_published')
+ search_fields = ['title', 'geo_title']
+ raw_id_fields = ('last_editor',)
+ list_filter = ['time_created', 'status', 'geo_type']
+ ordering = ['geo_title']
+
+
+@admin.register(Vacancy)
+class VacancyAdmin(admin.ModelAdmin):
+
+ list_display = ('title', 'src_alias', 'src_id', 'status', 'time_created')
+ search_fields = ['title', 'src_id']
+ list_filter = ['status', 'src_alias']
+ ordering = ['-time_created']
+
+
+class EntityBaseAdmin(SimpleHistoryAdmin):
+
+ list_display = ('time_created', 'title', 'submitter', 'time_published')
+ raw_id_fields = ['submitter', 'last_editor', 'linked']
+ search_fields = ['title', 'description']
+ list_filter = ['time_created', 'status']
+ ordering = ['-time_created']
+ readonly_fields = ['time_published', 'supporters_num']
+
+ actions = ['publish']
+
+ def publish(self, request: HttpRequest, queryset: QuerySet):
+
+ for obj in queryset:
+ obj.mark_published()
+ obj.save(notify_published=False)
+
+ publish.short_description = 'Опубликовать'
+
+
+@admin.register(Article)
+class ArticleAdmin(EntityBaseAdmin):
+
+ list_display = ('time_created', 'title', 'submitter', 'source', 'published_by_author', 'time_published')
+ list_filter = ['time_created', 'status', 'source', 'published_by_author']
+
+ actions = EntityBaseAdmin.actions + ['nofollow']
+
+ def nofollow(self, request: HttpRequest, queryset: QuerySet):
+ queryset.update(nofollow=True)
+
+ nofollow.short_description = 'Проставить nofollow'
+
+
+@admin.register(Book)
+class BookAdmin(EntityBaseAdmin):
+
+ form = BookForm
+
+ list_display = ('time_created', 'title', 'submitter', 'isbn', 'time_published')
+ search_fields = ['title', 'isbn']
+ inlines = [PartnerLinkInline]
+
+
+@admin.register(Community)
+class CommunityAdmin(EntityBaseAdmin):
+
+ search_fields = ['title', 'description', 'text']
+
+
+@admin.register(Discussion)
+class DiscussionAdmin(EntityBaseAdmin):
+
+ search_fields = ['title', 'description', 'text']
+
+
+@admin.register(Event)
+class EventAdmin(EntityBaseAdmin):
+
+ list_display = ('time_created', 'title', 'submitter', 'type', 'specialization', 'time_published')
+ search_fields = ['title', 'description', 'text']
+ list_filter = ['time_created', 'status', 'type', 'specialization']
+
+
+@admin.register(Video)
+class VideoAdmin(EntityBaseAdmin):
+ pass
+
+
+@admin.register(PEP)
+class PEPAdmin(EntityBaseAdmin):
+
+ list_display = ('num', 'title', 'type', 'status')
+ search_fields = ['title', 'description']
+ list_filter = ['status', 'type']
+ raw_id_fields = EntityBaseAdmin.raw_id_fields + [
+ 'versions', 'superseded', 'replaces', 'requires', 'linked', 'authors'
+ ]
+ readonly_fields = EntityBaseAdmin.readonly_fields + ['status', 'type', 'description']
+
+
+@admin.register(Reference)
+class ReferenceAdmin(HierarchicalModelAdmin, EntityBaseAdmin):
+
+ hierarchy = True
+ list_display = ('title', 'submitter', 'type', 'time_published')
+ list_filter = ['time_created', 'status', 'type', 'version_added', 'version_deprecated']
+
+
+@admin.register(ReferenceMissing)
+class ReferenceMissingAdmin(admin.ModelAdmin):
+
+ list_display = ('term', 'synonyms', 'hits')
+ search_fields = ['term', 'synonyms']
+ ordering = ['-hits', 'term']
+
+
+@admin.register(Version)
+class VersionAdmin(EntityBaseAdmin):
+
+ list_display = ('title', 'date', 'date_till', 'status', 'slug', 'time_published')
+
+
+@admin.register(Person)
+class PersonAdmin(admin.ModelAdmin):
+
+ list_display = ('name', 'name_en', 'time_created', 'time_published')
+ search_fields = ['name', 'name_en', 'aka']
+ list_filter = ['status']
+ ordering = ['name']
+ raw_id_fields = ['user', 'last_editor']
+ readonly_fields = ['text', 'supporters_num']
+
+
+@admin.register(App)
+class AppAdmin(EntityBaseAdmin):
+
+ list_display = ('time_created', 'title', 'slug', 'time_published')
+ search_fields = ['title', 'slug']
+ list_filter = ['status']
+
+ actions = EntityBaseAdmin.actions + ['update_stats']
+
+ def update_stats(self, request: HttpRequest, queryset: QuerySet):
+
+ App.actualize_downloads(queryset)
+
+ update_stats.short_description = 'Обновить данные о загрузках'
diff --git a/pythonz/apps/apps.py b/pythonz/apps/apps.py
new file mode 100644
index 00000000..283267a0
--- /dev/null
+++ b/pythonz/apps/apps.py
@@ -0,0 +1,15 @@
+from django.apps import AppConfig
+
+
+class PythonzAppsConfig(AppConfig):
+ """Конфигурация прриложений pythonz."""
+
+ name: str = 'pythonz.apps'
+ verbose_name: str = 'Сущности pythonz'
+
+ def ready(self):
+ from .realms import get_realms # noqa: PLC0415
+
+ for realm in get_realms().values():
+ # Привязываем область к моделям, она понадобится для вычисления URL.
+ realm.model.realm = realm
diff --git a/pythonz/apps/commands.py b/pythonz/apps/commands.py
new file mode 100644
index 00000000..f842885f
--- /dev/null
+++ b/pythonz/apps/commands.py
@@ -0,0 +1,61 @@
+from collections import defaultdict
+from datetime import timedelta
+
+from django.utils import timezone
+
+from .generics.models import RealmBaseModel
+from .models import ReferenceMissing
+from .realms import get_realms
+
+
+def publish_postponed():
+ """Публикует материалы, назначенные к отложенной публикации."""
+ status_postponed = RealmBaseModel.Status.POSTPONED
+ status_published = RealmBaseModel.Status.PUBLISHED
+
+ # В каждой области будем публиковать отложенные материалы,
+ # если данный автор не публиковался более узананного периода.
+ date_before = timezone.now() - timedelta(hours=22)
+
+ for realm in get_realms().values():
+ realm_model = realm.model
+ postponed_by_submitter = defaultdict(list)
+
+ qs = realm_model.objects.filter(status=status_postponed).order_by('time_created')
+
+ for item in qs:
+ submitter_id = getattr(item, 'submitter_id', None)
+
+ if submitter_id is None:
+ continue
+
+ postponed_by_submitter[submitter_id].append(item)
+
+ for submitter_id, items in postponed_by_submitter.items():
+
+ try:
+ latest = realm_model.objects.filter(
+ status=status_published,
+ submitter_id=submitter_id,
+ time_published__isnull=False,
+ ).latest('time_published')
+
+ except realm_model.DoesNotExist:
+ latest = None
+
+ if not latest or latest.time_published < date_before:
+ # Пока публикуем только первый из назначенных к публикации материалов.
+ # В последующем возможно логика будет усложнена.
+ item = items[0]
+ item.mark_published()
+ item.save()
+
+
+def clean_missing_refs(min_hits: int = 4):
+ """Удаляет из БД записи о промахах справочника, получившиеся
+ менее заданного количества обращений.
+
+ :param min_hits:
+
+ """
+ ReferenceMissing.objects.filter(hits__lt=min_hits).delete()
diff --git a/apps/exceptions.py b/pythonz/apps/exceptions.py
similarity index 77%
rename from apps/exceptions.py
rename to pythonz/apps/exceptions.py
index 81b5fead..4f93c401 100644
--- a/apps/exceptions.py
+++ b/pythonz/apps/exceptions.py
@@ -2,8 +2,8 @@
class PythonzException(Exception):
"""Базовое исключение проекта. Остальные должны наследоваться от него."""
- def __init__(self, message=None, **kwargs):
- super().__init__(message, **kwargs)
+ def __init__(self, message: str = None, **kwargs):
+ super().__init__(message)
self.message = message
@@ -17,3 +17,5 @@ class RedirectRequired(PythonzException):
"""
+class LogicError(PythonzException):
+ """Логическая ошибка в приложении."""
diff --git a/apps/generics/__init__.py b/pythonz/apps/forms/__init__.py
similarity index 100%
rename from apps/generics/__init__.py
rename to pythonz/apps/forms/__init__.py
diff --git a/apps/forms/forms.py b/pythonz/apps/forms/forms.py
similarity index 57%
rename from apps/forms/forms.py
rename to pythonz/apps/forms/forms.py
index f1b9c7a7..fc33c700 100644
--- a/apps/forms/forms.py
+++ b/pythonz/apps/forms/forms.py
@@ -1,18 +1,22 @@
-from django.conf import settings
+
from django import forms
+from django.conf import settings
from django.contrib.contenttypes.models import ContentType
-from datetimewidget.widgets import DateTimeWidget
-from apps.exceptions import RemoteSourceError
-from apps.shortcuts import message_error
-from ..models import Book, Video, Event, Discussion, User, Article, Community, Reference
from ..generics.forms import RealmEditBaseForm
-from .widgets import RstEditWidget, ReadOnlyWidget, PlaceWidget
+from ..generics.models import CommonEntityModel, RealmBaseModel
+from ..generics.realms import RealmBase
+from ..integration.videos import VideoBroker
+from ..models import App, Article, Book, Community, Discussion, Event, Reference, User, Version, Video
+from .widgets import RstEditWidget
class DiscussionForm(RealmEditBaseForm):
- class Meta:
+ hidden_fields = {'object_id', 'content_type'}
+
+ class Meta(RealmEditBaseForm.Meta):
+
model = Discussion
fields = (
'title',
@@ -21,24 +25,27 @@ class Meta:
'content_type',
)
labels = {'text_src': ''}
- widgets = {
- 'object_id': forms.HiddenInput(),
- 'content_type': forms.HiddenInput(),
- }
@classmethod
- def _get_realm_item(cls, realm, item_id):
- """Вернёт объект из указанной области по иднетификтаору, либо None.
+ def _get_realm_item(
+ cls,
+ realm: type[RealmBase],
+ item_id: int
+ ) -> RealmBaseModel | CommonEntityModel | None:
+ """Вернёт объект из указанной области по идентификтаору, либо None.
+
+ :param realm:
+ :param item_id:
- :param RealmBase realm:
- :param int item_id:
- :return:
"""
item = None
+
try:
item = realm.model.objects.get(pk=item_id)
+
except realm.model.DoesNotExist:
pass
+
return item
def __init__(self, *args, **kwargs):
@@ -48,20 +55,18 @@ def __init__(self, *args, **kwargs):
# Вызов со страницы сущности одной из областей.
# Связываем сущность с обсуждением.
- from ..realms import get_realm
+ from ..realms import get_realm # noqa: PLC0415
- realm = get_realm(data['related_item_realm'])
- if realm is not None:
+ if realm := get_realm(data['related_item_realm']) is not None:
item_id = data['related_item_id']
- item = self._get_realm_item(realm, item_id)
- if item is not None:
+ if item := self._get_realm_item(realm, item_id) is not None:
data = dict(data)
data['object_id'] = item_id
data['content_type'] = ContentType.objects.get_for_model(item).id
- data['title'] = '%s про «%s»' % (kwargs['user'].get_display_name(), item.title)
+ data['title'] = f"{kwargs['user'].get_display_name()} про «{item.title}»"
args = list(args)
args[0] = data
@@ -69,13 +74,39 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+class VersionForm(RealmEditBaseForm):
+
+ class Composer(RealmEditBaseForm.Composer):
+
+ attrs = {
+ 'text_src': {'rows': 25},
+ }
+
+ class Meta(RealmEditBaseForm.Meta):
+
+ model = Version
+ fields = (
+ 'title',
+ 'date',
+ 'date_till',
+ 'status',
+ 'description',
+ 'text_src',
+ )
+
+
class ArticleForm(RealmEditBaseForm):
+
+ class Composer(RealmEditBaseForm.Composer):
+
+ attrs = {
+ 'text_src': {'rows': 25},
+ }
- class Meta:
+ class Meta(RealmEditBaseForm.Meta):
+
model = Article
fields = (
- 'location',
- 'url',
'title',
'description',
'status',
@@ -90,44 +121,48 @@ class Meta:
'text_src': RstEditWidget(attrs={'rows': 25}),
}
- def make_fields_optional(self, fields):
+ def set_fields_required(self, fields: list[str], *, required: bool = True):
for field in fields:
- self.fields[field].required = False
+ self.fields[field].required = required
def full_clean(self):
if self.data:
+
try:
location = self.fields['location'].clean(self.data.get('location'))
- except forms.ValidationError:
+
+ except (forms.ValidationError, KeyError):
pass
+
else:
if location == Article.LOCATION_INTERNAL:
self.data['url'] = None
- self.make_fields_optional(['url'])
+ self.set_fields_required(['url'], required=False)
elif location == Article.LOCATION_EXTERNAL:
- self.make_fields_optional(['url', 'title', 'description', 'text_src'])
+ self.set_fields_required(['url'])
+ self.set_fields_required(['title', 'description', 'text_src'], required=False)
return super().full_clean()
- def clean_url(self):
- url = self.cleaned_data['url']
- if not url:
- url = None
- return url
+ def clean_url(self) -> str | None:
+ return self.cleaned_data['url'].strip() or None
+
+ def save(self, *args, **kwargs):
+ url = self.cleaned_data.get('url')
- def save(self, commit=True):
- url = self.cleaned_data['url']
if url:
self.instance.update_data_from_url(url)
- return super().save(commit)
+
+ return super().save(*args, **kwargs)
class BookForm(RealmEditBaseForm):
- class Meta:
+ class Meta(RealmEditBaseForm.Meta):
+
model = Book
fields = (
'title',
@@ -142,26 +177,30 @@ class Meta:
)
@staticmethod
- def clean_isbn_(isbn):
+ def clean_isbn_(isbn) -> str | None:
+ isbn = isbn or ''
isbn = isbn.replace('-', '').strip()
- length = len(isbn)
- if length:
+
+ if length := len(isbn):
if (length != 10 and length != 13) or not isbn.isdigit():
raise forms.ValidationError('Код ISBN должен содержать 10, либо 13 цифр.')
+
else:
isbn = None
+
return isbn
- def clean_isbn(self):
+ def clean_isbn(self) -> str | None:
return self.clean_isbn_(self.cleaned_data['isbn'])
- def clean_isbn_ebook(self):
+ def clean_isbn_ebook(self) -> str | None:
return self.clean_isbn_(self.cleaned_data['isbn_ebook'])
class VideoForm(RealmEditBaseForm):
- class Meta:
+ class Meta(RealmEditBaseForm.Meta):
+
model = Video
fields = (
'title',
@@ -173,16 +212,18 @@ class Meta:
'year',
)
help_texts = {
- 'url': 'URL страницы с видео. Умеем работать с %s' % ', '.join(Video.get_supported_hostings()),
+ 'url': f"URL страницы с видео. Умеем работать с {', '.join(Video.get_supported_hostings())}",
}
- def clean_url(self):
+ def clean_url(self) -> str:
url = self.cleaned_data['url']
- if not Video.get_hosting_for_url(url):
+
+ if not VideoBroker.get_hosting_for_url(url):
raise forms.ValidationError(
'К сожалению, мы не умеем работать с этим видео-хостингом. '
- 'Если знаете, как это исправить, приходите сюда .' % settings.PROJECT_SOURCE_URL
+ f'Если знаете, как это исправить, приходите сюда .'
)
+
return url
def save(self, *args, **kwargs):
@@ -190,15 +231,10 @@ def save(self, *args, **kwargs):
return super().save(*args, **kwargs)
-CALENDAR_OPTIONS = {
- 'todayHighlight': True,
- 'weekStart': 1
-}
-
-
class EventForm(RealmEditBaseForm):
- class Meta:
+ class Meta(RealmEditBaseForm.Meta):
+
model = Event
fields = (
'title',
@@ -214,60 +250,60 @@ class Meta:
'place',
'text_src',
)
- widgets = {
- 'place': PlaceWidget(),
- 'time_start': DateTimeWidget(usel10n=True, options=CALENDAR_OPTIONS, bootstrap_version=3),
- 'time_finish': DateTimeWidget(usel10n=True, options=CALENDAR_OPTIONS, bootstrap_version=3),
- }
class UserForm(RealmEditBaseForm):
- class Meta:
+ readonly_fields = {'timezone'}
+
+ class Meta(RealmEditBaseForm.Meta):
+
model = User
fields = (
'first_name',
'last_name',
- 'url',
- 'email_public',
+ 'profile_public',
'place',
'timezone',
- 'comments_enabled',
- 'disqus_shortname',
- 'disqus_category_id',
+ 'url',
+ 'email_public',
)
- widgets = {
- 'place': PlaceWidget(),
- 'timezone': ReadOnlyWidget(),
- }
- def save(self, commit=True):
+ def save(self, *args, **kwargs):
+
if 'place' in self.changed_data:
self.instance.set_timezone_from_place()
- super().save(commit=commit)
+
+ super().save(*args, **kwargs)
class CommunityForm(RealmEditBaseForm):
- class Meta:
+ class Meta(RealmEditBaseForm.Meta):
+
model = Community
fields = (
'title',
'url',
+ 'cover',
'description',
'text_src',
'contacts',
'place',
'year',
)
- widgets = {
- 'place': PlaceWidget(),
- }
class ReferenceForm(RealmEditBaseForm):
- class Meta:
+ class Composer(RealmEditBaseForm.Composer):
+
+ attrs = {
+ 'func_params': {'rows': 4},
+ }
+
+ class Meta(RealmEditBaseForm.Meta):
+
model = Reference
fields = (
'status',
@@ -279,9 +315,27 @@ class Meta:
'func_params',
'func_result',
'text_src',
+ 'pep',
'version_added',
'version_deprecated',
+ 'search_terms',
)
- widgets = {
- 'func_params': forms.Textarea(attrs={'rows': 4}),
- }
+
+
+class AppForm(RealmEditBaseForm):
+
+ class Meta(RealmEditBaseForm.Meta):
+
+ model = App
+ fields = (
+ 'title',
+ 'slug',
+ 'description',
+ 'repo',
+ 'status',
+ 'author',
+ 'text_src',
+ )
+
+ def clean_slug(self) -> str | None:
+ return self.cleaned_data.get('slug', '').strip() or None
diff --git a/apps/forms/widgets.py b/pythonz/apps/forms/widgets.py
similarity index 56%
rename from apps/forms/widgets.py
rename to pythonz/apps/forms/widgets.py
index 69991090..e394976a 100644
--- a/apps/forms/widgets.py
+++ b/pythonz/apps/forms/widgets.py
@@ -1,44 +1,38 @@
+
from django import forms
-from django.forms.widgets import TextInput
from django.forms.utils import flatatt
-from django.utils.html import format_html, force_text
+from django.forms.widgets import TextInput
from django.template import loader
+from django.utils.encoding import force_str
+from django.utils.html import format_html
from ..models import Place
-class ReadOnlyWidget(forms.Widget):
- """Представляет поле только для чтения."""
-
- def value_from_datadict(self, data, files, name):
- return getattr(self.model, self.field_name) # Чтобы поле не считалось изменённым.
-
- def render(self, name, value, attrs=None):
- if hasattr(self, 'initial'):
- value = self.initial
- return '%s' % (value or '')
+class PlaceWidget(TextInput):
+ """Представляет поле для редактирования места."""
+ _place_cached: bool = False
+ _place_cache: Place = None
-class PlaceWidget(TextInput):
- """Представляет поле для редактирования местом."""
+ def render(self, name, value, attrs=None, renderer=None) -> str:
- _place_cached = False
- _place_cache = None
+ model = self.bound_field.form.instance
+ if value and model:
+ value = model.place.geo_title # Выводим полное название места.
- def render(self, name, value, attrs=None):
- if value:
- value = self.model.place.geo_title # Выводим полное название места.
return super().render(name, value, attrs=attrs)
- def value_from_datadict(self, data, files, name):
+ def value_from_datadict(self, data, files, name) -> str | int:
"""Здесь получаем из строки наименования места объект места.
:param data:
:param files:
:param name:
- :return:
+
"""
place_name = data.get(name, None)
+
if not place_name:
return ''
@@ -48,6 +42,7 @@ def value_from_datadict(self, data, files, name):
if self._place_cache is None:
return ''
+
return self._place_cache.id
@@ -55,17 +50,21 @@ class RstEditWidget(forms.Widget):
"""Реализует виджет для редактирования и предпросмотра текста в rst-подобном формате."""
def __init__(self, attrs=None):
+
default_attrs = {'cols': '40', 'rows': '10'}
+
if attrs:
default_attrs.update(attrs)
+
super().__init__(default_attrs)
- def render(self, name, value, attrs=None):
+ def render(self, name, value, attrs=None, renderer=None) -> str:
if value is None:
value = ''
- final_attrs = self.build_attrs(attrs, name=name)
+ final_attrs = self.build_attrs(attrs, {'name': name})
+
+ html = loader.render_to_string('sub/rst_hints.html')
- html = loader.render_to_string('sub_rst_hints.html')
- return format_html(html, flatatt(final_attrs), force_text(value))
+ return format_html(html, flatatt(final_attrs), force_str(value))
diff --git a/apps/migrations/__init__.py b/pythonz/apps/generics/__init__.py
similarity index 100%
rename from apps/migrations/__init__.py
rename to pythonz/apps/generics/__init__.py
diff --git a/pythonz/apps/generics/forms.py b/pythonz/apps/generics/forms.py
new file mode 100644
index 00000000..2bb27cee
--- /dev/null
+++ b/pythonz/apps/generics/forms.py
@@ -0,0 +1,64 @@
+from django import forms
+from django.http import HttpRequest
+from django.utils import timezone
+from siteforms.composers.bootstrap4 import SUBMIT, Bootstrap4
+from siteforms.toolbox import ModelForm
+
+from ..forms.widgets import PlaceWidget, RstEditWidget
+from ..models import Article, User
+
+
+class CommonEntityForm(ModelForm):
+ """Базовый класс для форм создания/редактирования сущностей."""
+
+ pythonz_form = forms.CharField(widget=forms.HiddenInput(), initial='1')
+
+ def __init__(self, *args, request: HttpRequest = None, src: str = None, id: str = '', **kwargs):
+ super().__init__(*args, request=request, src=src, id=id, **kwargs)
+
+ def clean_year(self) -> str:
+ year = (self.cleaned_data['year'] or '').strip()
+
+ if year:
+ if len(year) != 4 or not year.isdigit() or not (1900 < int(year) <= timezone.now().year):
+ raise forms.ValidationError('Такой год не похож на правду.')
+
+ return year
+
+ class Composer(Bootstrap4):
+
+ opt_submit = 'Сохранить'
+
+ attrs = {
+ SUBMIT: {'class': 'btn btn-block btn-success'},
+ }
+
+
+class RealmEditBaseForm(CommonEntityForm):
+ """Базовый класс для форм создания/редактирования сущностей, принадлежащим областям."""
+
+ def __init__(self, *args, user: User | None = None, **kwargs):
+
+ super().__init__(*args, **kwargs)
+
+ if self._meta.model is not Article: # Запрещаем управлять статусом везде, кроме Статей.
+
+ if user is not None:
+ if not user.is_superuser and not user.is_staff:
+ try:
+ del self.fields['status']
+
+ except KeyError: # Нет такого поля на форме.
+ pass
+
+ class Composer(CommonEntityForm.Composer):
+
+ attrs = {
+ 'description': {'rows': 3},
+ }
+
+ class Meta:
+ widgets = {
+ 'text_src': RstEditWidget(attrs={'rows': 12}),
+ 'place': PlaceWidget(),
+ }
diff --git a/pythonz/apps/generics/models.py b/pythonz/apps/generics/models.py
new file mode 100644
index 00000000..2d9d6882
--- /dev/null
+++ b/pythonz/apps/generics/models.py
@@ -0,0 +1,763 @@
+import os
+from contextlib import suppress
+from copy import copy
+from datetime import datetime
+from enum import unique
+from typing import TYPE_CHECKING, Optional
+from uuid import uuid4
+
+from django.conf import settings
+from django.core.cache import cache
+from django.db import models
+from django.db.models import Model, QuerySet
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.functional import cached_property
+from django.utils.html import urlize
+from django.utils.text import Truncator
+from etc.models import InheritedModelMetaclass
+from siteflags.models import ModelWithFlag
+from slugify import CYRILLIC, Slugify
+
+from ..integration.base import RemoteSource
+from ..integration.utils import get_image_from_url
+from ..signals import sig_entity_new, sig_entity_published, sig_support_changed
+from ..utils import UTM, BasicTypograph, TextCompiler
+
+USER_MODEL: str = settings.AUTH_USER_MODEL
+SLUGIFIER = Slugify(pretranslate=CYRILLIC, to_lower=True, safe_chars='-._', max_length=200)
+
+
+if TYPE_CHECKING:
+ from ..models import Category, User
+ from .forms import CommonEntityForm
+ from .realms import RealmBase
+
+
+class ModelWithAuthorAndTranslator(models.Model):
+ """Класс-примесь для моделей, требующих поля с автором и переводчиком."""
+
+ _hint_userlink: str = (
+ '[u:<ид>:<имя>] формирует ссылку на профиль пользователя pythonz. Например: [u:1:идле].')
+
+ author = models.CharField(
+ 'Автор', max_length=255,
+ help_text=f'Предпочтительно имя и фамилия. Можно указать несколько, разделяя запятыми.{_hint_userlink}')
+
+ translator = models.CharField(
+ 'Перевод', max_length=255, blank=True, null=True,
+ help_text=('Укажите переводчиков, если материал переведён на русский с другого языка. '
+ f'Если переводчик неизвестен, можно указать главного редактора.{_hint_userlink}'))
+
+ class Meta:
+ abstract = True
+
+
+class ModelWithCompiledText(models.Model):
+ """Класс-примесь для моделей, требующих поля, содержащие тексты в rst."""
+
+ text = models.TextField('Текст')
+ text_src = models.TextField('Исходный текст')
+
+ class Meta:
+ abstract = True
+
+ @classmethod
+ def compile_text(cls, text: str) -> str:
+ """Преобразует rst-подобное форматирование в html.
+
+ :param text:
+
+ """
+ return TextCompiler.compile(text)
+
+ def save(self, *args, **kwargs):
+ self.text = self.compile_text(self.text_src)
+ super().save(*args, **kwargs)
+
+
+def get_upload_to(instance: Model, filename: str) -> str:
+ """Вычисляет директорию, в которую будет загружена обложка сущности.
+
+ :param instance:
+ :param filename:
+
+ """
+ category = instance.COVER_UPLOAD_TO
+
+ return os.path.join('img', category, 'orig', f'{uuid4()}{os.path.splitext(filename)[-1]}') # noqa PTH122,PTH118
+
+
+class CommonEntityModel(models.Model):
+ """Базовый класс для моделей сущностей."""
+
+ COVER_UPLOAD_TO = 'common' # Имя категории (оно же имя директории) для хранения загруженных обложек.
+
+ title = models.CharField('Название', max_length=255)
+ slug = models.CharField('Краткое имя для URL', max_length=200, null=True, blank=True, unique=True)
+ description = models.TextField('Описание', blank=False, null=False)
+ cover = models.ImageField('Обложка', max_length=255, upload_to=get_upload_to, null=True, blank=True)
+ year = models.CharField('Год', max_length=10, null=True, blank=True)
+
+ linked = models.ManyToManyField(
+ 'self', verbose_name='Связанные объекты', blank=True,
+ help_text='Выберите объекты, имеющие отношение к данному.')
+
+ class Meta:
+ abstract = True
+
+ slug_pick: bool = False
+ """Дозволено ли обращение к записям по их краткому имени."""
+
+ slug_auto: bool = False
+ """Следует ли автоматически генерировать краткое имя в транслите для URL.
+ Предполагается, что эта опция также включает машинерию, позволяющую адресовать
+ объект по его краткому имени.
+
+ """
+
+ allow_linked: bool = True
+ """Разрешена ли привязка элементов друг к другу."""
+
+ def generate_slug(self) -> str:
+ """Генерирует краткое имя для URL и заполняет им атрибут slug."""
+ return SLUGIFIER(self.title)
+
+ def validate_unique(self, exclude: set[str] = None):
+
+ # Перекрываем для правильной обработки спарки unique=True и null=True
+ # в поле краткого имени URL.
+
+ if not exclude:
+ exclude = set()
+
+ if not self.slug:
+ exclude.update('slug')
+
+ return super().validate_unique(exclude)
+
+ def save(self, *args, **kwargs):
+ """Перекрыт, чтобы привести заголовок в порядок.
+
+ :param args:
+ :param kwargs:
+
+ """
+ self.title = BasicTypograph.apply_to(self.title)
+ self.description = BasicTypograph.apply_to(self.description)
+
+ if not self.id and self.slug_auto:
+ self.slug = self.generate_slug()
+
+ # Требуется для правильной обработки спарки unique=True и null=True
+ if not self.slug:
+ self.slug = None
+
+ super().save(*args, **kwargs)
+
+ def get_description(self) -> str:
+ """Возвращает вычисляемое описание объекта.
+ Обычно должен использоваться вместо обращения к атрибуту description,
+ которого может не сущестовать у модели.
+
+ """
+ return self.description
+
+ def update_cover_from_url(self, url: str):
+ """Забирает обложку с указанного URL.
+
+ :param url:
+
+ """
+ if '.svg' in url:
+ # todo PIL не умеет работать с вектором. Не будем пытаться.
+ # Наивно орентируемся на наличие расширения в целях экономии ресурсов.
+ return
+
+ img = get_image_from_url(url)
+
+ if img is not None:
+ self.cover.save(img.name, img, save=False)
+
+ def get_linked(self) -> QuerySet:
+ """Возвращает связанные объекты."""
+
+ if self.allow_linked:
+ return self.linked.all()
+
+ return QuerySet(self.__class__).none()
+
+ @classmethod
+ def get_paginator_objects(cls) -> QuerySet:
+ """Возвращает выборку объектов для постраничной навигации.
+ Должен быть реализован наследниками.
+
+ """
+ raise NotImplementedError # pragma: nocover
+
+ @cached_property
+ def get_short_description(self) -> str:
+ """Возвращает усечённое описание сущности."""
+ return Truncator(self.description).words(25)
+
+ def __str__(self) -> str:
+ return self.title
+
+
+class RealmFilteredQuerySet(models.QuerySet):
+ """Реализует поддержку запросов с фильтрами для обсластей."""
+
+ def published(self) -> QuerySet:
+ """Возвращает только опубликованные сущности."""
+ return self.filter(status=RealmBaseModel.Status.PUBLISHED)
+
+ def postponed(self) -> QuerySet:
+ """Возвращает только сущности, назначенные к отложенной публикации."""
+ return self.filter(status=RealmBaseModel.Status.POSTPONED)
+
+
+class RealmBaseModel(ModelWithFlag):
+ """Базовый класс для моделей, использующихся в областях (realms) сайта."""
+
+ @unique
+ class Status(models.IntegerChoices):
+
+ DRAFT = 1, 'Черновик'
+ PUBLISHED = 2, 'Опубликован'
+ DELETED = 3, 'Удален'
+ ARCHIVED = 4, 'В архиве'
+ POSTPONED = 5, 'К отложенной публикации'
+
+ FLAG_STATUS_BOOKMARK = 1
+ """Идентификатор флагов-закладок."""
+
+ FLAG_STATUS_SUPPORT = 2
+ """Идентификатор флагов-голосов-поддержки."""
+
+ objects = RealmFilteredQuerySet().as_manager()
+
+ time_created = models.DateTimeField('Дата создания', auto_now_add=True, editable=False)
+ time_published = models.DateTimeField('Дата публикации', null=True, editable=False)
+ time_modified = models.DateTimeField('Дата редактирования', null=True, editable=False)
+ status = models.PositiveIntegerField('Статус', choices=Status.choices, default=Status.DRAFT)
+ supporters_num = models.PositiveIntegerField('Поддержка', default=0)
+
+ submitter = models.ForeignKey(
+ USER_MODEL, verbose_name='Добавил', related_name='%(class)s_submitters', default=settings.ROBOT_USER_ID,
+ on_delete=models.SET_DEFAULT)
+ last_editor = models.ForeignKey(
+ USER_MODEL, verbose_name='Редактор', related_name='%(class)s_editors', null=True, blank=True,
+ help_text='Пользователь, последним отредактировавший объект.', default=settings.ROBOT_USER_ID,
+ on_delete=models.SET_DEFAULT,)
+
+ class Meta:
+ abstract = True
+
+ realm: 'RealmBase' = None
+ """Во время исполнения здесь будет объект области (Realm)."""
+
+ edit_form: 'CommonEntityForm' = None
+ """Во время исполнения здесь будет форма редактирования."""
+
+ items_per_page: int = 10
+ """Количество объектов для вывода на страницах списков."""
+
+ notify_on_publish: bool = True
+ """Следует ли оповещать внешние системы о публикации сущности."""
+
+ allow_edit_anybody: bool = True
+ """Дозволено редактирования любому пользователю (не автору)."""
+
+ allow_edit_published: bool = False
+ """Дозволено редактирования опубликованных материалов."""
+
+ paginator_order: str = '-time_created'
+ """Поле, по которому следует сортировать объекты
+ при обращении к постраничному списку.
+
+ """
+
+ paginator_defer: list[str] = []
+ """Тяжелые поля, содержимое которых не важно для списков."""
+
+ paginator_related: list[str] = ['submitter']
+ """Поле, из которого следует тянуть данные одним запросом
+ при обращении к постраничному списку объектов.
+
+ """
+
+ details_related: list[str] = ['submitter', 'last_editor']
+ """Поле, из которого следует тянуть данные одним запросом
+ при обращении странице с детальной информацией по объекту.
+
+ """
+
+ @classmethod
+ def make_html(cls, text: str) -> str:
+ """Применяет базовую html-разметку к указанному тексту.
+
+ :param text:
+
+ """
+ return urlize(text.replace('\n', ' '), nofollow=True)
+
+ def mark_published(self):
+ """Помечает материал опубликованным."""
+ self.status = self.Status.PUBLISHED
+ self._consider_published = True
+
+ def mark_unmodified(self):
+ """Используется для того, чтобы при следующем вызове save()
+ объекта он не считался изменённым.
+
+ """
+ self._consider_modified = False
+
+ @property
+ def turbo_content(self) -> str:
+ return ''
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self._consider_published = False
+ """Указывает на то, следует ли считать сущность опубликованной."""
+
+ self._consider_modified = True
+ """Указывает на то, нужно ли при сохранении устанавливать время изменения"""
+
+ self._status_backup = self.status
+
+ def on_publish(self):
+ """Вызывается при смене стутуса на «Опубликовано»."""
+
+ def save(self, *args, **kwargs):
+ """Перекрыт, чтобы можно было отследить флаг модифицированности объекта
+ и выставить время модификации соответствующим образом.
+
+ :param args:
+
+ :param kwargs: Среди прочего, поддерживаются:
+
+ notify_published - флаг, указывающий на то, требуется ли отослать
+ оповещения о публикации сущности.
+
+ notify_new - флаг, указывающий на то, требуется ли отослать
+ оповещения о создании сущности.
+ """
+ initial_pk = self.pk
+ notify_published = kwargs.pop('notify_published', None)
+ notify_new = kwargs.pop('notify_new', True)
+
+ now = timezone.now()
+
+ if (status_changed := self._status_backup != self.status) or self._consider_published:
+
+ if status_changed:
+ # Если сохраняем с переходом статуса, наивно полагаем объект немодифицированным.
+ self._consider_modified = False
+
+ if self.is_published:
+ self.on_publish()
+
+ if self.is_published:
+ self.time_published = now
+ self._consider_published = False
+
+ if notify_published is None:
+ notify_published = True
+
+ if self._consider_modified:
+ self.time_modified = now
+
+ else:
+ self._consider_modified = True
+
+ super().save(*args, **kwargs)
+
+ with suppress(AttributeError): # Пропускаем модели, в которых нет нужных атрибутов.
+
+ if notify_new and (not initial_pk and self.pk):
+ sig_entity_new.send(self.__class__, entity=self)
+
+ with suppress(AttributeError): # Пропускаем модели, в которых нет нужных атрибутов.
+ notify_published and sig_entity_published.send(self.__class__, entity=self)
+
+ @classmethod
+ def find(cls, *search_terms: str) -> QuerySet:
+ """Ищет указанный текст в данных модели. Возвращает QuerySet.
+
+ :param search_terms: Строки для поиска.
+
+ """
+ raise NotImplementedError # pragma: nocover
+
+ @classmethod
+ def get_actual(cls, **kwargs) -> QuerySet:
+ """Возвращает выборку актуальных объектов."""
+ return cls.objects.published().order_by('-time_published').all()
+
+ @classmethod
+ def get_featured(
+ cls,
+ *,
+ candidate: 'RealmBaseModel',
+ dt_stale: datetime
+ ) -> Optional['RealmBaseModel']:
+ """Возвращает объект «подсвеченный» (особо выделенный на главной)
+ объект, либо None.
+
+ :param candidate: Ранее выбранный кандидат на «подсвеченность».
+
+ :param dt_stale: Дата, начиная с которой следует считать материал устаревшим.
+
+ """
+ if not candidate:
+ return candidate
+
+ featured = candidate
+
+ if (candidate.time_modified or candidate.time_created) < dt_stale:
+ # Объект устарел, покажем что-нибудь случайное.
+ # ? ведёт себя сносно, пока таблица влезает в память.
+ featured = cls.get_actual().filter(id__lt=candidate.id).order_by('?').first() or candidate
+
+ return featured
+
+ @classmethod
+ def get_paginator_objects(cls) -> QuerySet:
+ """Возвращает выборку для постраничной навигации."""
+
+ qs = cls.objects.published()
+
+ related = cls.paginator_related
+ if related:
+ qs = qs.select_related(*related)
+
+ defer = cls.paginator_defer
+ if defer:
+ qs = qs.defer(*defer)
+
+ qs = qs.order_by(cls.paginator_order)
+
+ return qs
+
+ @classmethod
+ def cache_get_key_most_voted_objects(cls, category: 'Category' = None, class_name: str = None) -> str:
+ """Возвращает ключ кеша, содержащего наиболее популярные материалы раздела.
+
+ :param category:
+ :param class_name:
+
+ """
+ if class_name is None:
+ class_name = cls.__name__
+
+ return f'most_voted|{class_name}|{category}'
+
+ @classmethod
+ def get_most_voted_objects(cls, category: 'Category' = None, base_query: QuerySet = None) -> QuerySet:
+ """Возвращает наиболее популярные материалы раздела (и, опционально, категории в нём).
+
+ :param category:
+ :param base_query:
+
+ """
+ cache_key = cls.cache_get_key_most_voted_objects(category=category)
+ objects = cache.get(cache_key)
+
+ if objects is None:
+
+ if base_query is None:
+ base_query = cls.objects.published()
+
+ query = base_query.filter(supporters_num__gt=0)
+
+ if cls.paginator_related:
+ query = query.select_related(*cls.paginator_related)
+
+ query = query.order_by('-supporters_num')
+ objects = query.all()[:3]
+
+ cache.set(cache_key, objects, 86400)
+
+ return objects
+
+ @classmethod
+ def cache_delete_most_voted_objects(cls, **kwargs):
+ """Очищает кеш наиболее популярных материлов раздела.
+
+ :param kwargs:
+
+ """
+ # TODO Не инвалидирует кеш в категориях раздела. При случае решить, а нужно ли вообще.
+ cache.delete(cls.cache_get_key_most_voted_objects(class_name=kwargs['sender']))
+
+ @property
+ def is_draft(self) -> bool:
+ """Возвращает булево указывающее на то, является ли сущность черновиком."""
+ return self.status == self.Status.DRAFT
+
+ @property
+ def is_deleted(self) -> bool:
+ """Возвращает булево указывающее на то, помечена ли сущность удаленной."""
+ return self.status == self.Status.DELETED
+
+ @property
+ def is_published(self) -> bool:
+ """Возвращает булево указывающее на то, опубликована ли сущность."""
+ return self.status == self.Status.PUBLISHED
+
+ def is_supported_by(self, user: 'User') -> bool:
+ """Возвращает указание на то, поддерживает ли данный пользователь данную сущность.
+
+ :param user:
+
+ """
+ return self.is_flagged(user, status=self.FLAG_STATUS_SUPPORT)
+
+ @classmethod
+ def get_category_objects_base_query(cls, category: 'Category') -> QuerySet:
+ """Возвращает базовый QuerySet выборки объектов в указанной категории.
+
+ :param category:
+
+ """
+ return cls.get_from_category_qs( # ModelWithCategory
+ category
+ ).filter(status=RealmBaseModel.Status.PUBLISHED).select_related('submitter')
+
+ @classmethod
+ def get_most_voted_objects_in_category(cls, category: 'Category') -> QuerySet:
+ """Возвращает наиболее популярные объекты из указанной категории.
+
+ :param category:
+
+ """
+ return cls.get_most_voted_objects(category=category, base_query=cls.get_category_objects_base_query(category))
+
+ @classmethod
+ def get_objects_in_category(cls, category: 'Category') -> QuerySet:
+ """Возвращает объекты из указанной категории.
+
+ :param category:
+
+ """
+ return cls.get_category_objects_base_query(category).order_by('-time_published')
+
+ def set_support(self, user: 'User'):
+ """Устанавливает флаг поддержки данным пользователем данной сущности.
+
+ :param user:
+
+ """
+ self.supporters_num += 1
+ self.set_flag(user, status=self.FLAG_STATUS_SUPPORT)
+ self.mark_unmodified()
+ self.save()
+
+ sig_support_changed.send(self.__class__.__name__)
+
+ def remove_support(self, user: 'User'):
+ """Убирает флаг поддержки данным пользователем данной сущности.
+
+ :param user:
+
+ """
+ self.supporters_num -= 1
+ self.remove_flag(user, status=self.FLAG_STATUS_SUPPORT)
+ self.mark_unmodified()
+ self.save()
+
+ sig_support_changed.send(self.__class__.__name__)
+
+ def get_suppport_for_objects(self, objects_list: QuerySet, user: 'User') -> dict:
+ """Возвращает данные о поддержке пользователем(ями) указанного набора сущностей.
+
+ :param objects_list:
+ :param user:
+
+ """
+ return self.get_flags_for_objects(objects_list, user=user)
+
+ def is_bookmarked_by(self, user: 'User') -> bool:
+ """Возвращает указание на то, добавил ли данный пользователь данную сущность в избранное.
+
+ :param user:
+
+ """
+ return self.is_flagged(user, status=self.FLAG_STATUS_BOOKMARK)
+
+ def set_bookmark(self, user: 'User'):
+ """Добавляет данную сущность в избранные для данного пользователя.
+
+ :param user:
+
+ """
+ self.set_flag(user, status=self.FLAG_STATUS_BOOKMARK)
+
+ def remove_bookmark(self, user: 'User'):
+ """Убирает данную сущность из избранного данного пользователя.
+
+ :param user:
+
+ """
+ self.remove_flag(user, status=self.FLAG_STATUS_BOOKMARK)
+
+ @classmethod
+ def get_verbose_name(cls) -> str:
+ """Возвращает человекоудобное название типа объекта в ед. числе."""
+ return cls._meta.verbose_name
+
+ @classmethod
+ def get_verbose_name_plural(cls) -> str:
+ """Возвращает человекоудобное название типа объекта во мн. числе."""
+ return cls._meta.verbose_name_plural
+
+ def was_edited(self) -> bool:
+ """Возвращает флаг, указывающий на то, был ли объект отредактирован
+ (различаются ли даты создания и редактирования).
+
+ """
+
+ def format_date(date: datetime):
+ return date.strftime('%Y%m%d%H%i')
+
+ return self.time_modified and format_date(self.time_modified) != format_date(self.time_created)
+
+ def get_absolute_url(self, *, with_prefix: bool = False, utm_source: str = None) -> str:
+ """Возвращает URL страницы с детальной информацией об объекте.
+
+ :param with_prefix: Флаг. Следует ли добавлять название хоста к URL.
+
+ :param utm_source: Строка для создания UTM-метки (Urchin Tracking Module).
+ Используются для обозначения источников переходов по URL при сборе статистики посещений.
+
+ """
+ details_urlname = self.realm.get_details_urlname()
+
+ id_attr = getattr(self, 'slug', None)
+
+ if id_attr:
+ details_urlname += '_slug'
+
+ else:
+ id_attr = self.id
+
+ url = reverse(details_urlname, args=[str(id_attr)])
+
+ if with_prefix:
+ url = f'{settings.SITE_URL}{url}'
+
+ if utm_source is not None:
+ url = UTM.add_to_internal_url(url, utm_source)
+
+ return url
+
+ @property
+ def absolute_url_prefixed(self) -> str:
+ return self.get_absolute_url(with_prefix=True)
+
+ @classmethod
+ def get_category_absolute_url(cls, category: 'Category') -> str:
+ """Возвращает URL страницы с разбивкой по данной категории.
+
+ :param category:
+
+ """
+ tmp, realm_name_plural = cls.realm.get_names()
+
+ return reverse(f'{realm_name_plural}:tags', args=[str(category.id)])
+
+ def get_display_name(self) -> str:
+ """Имя для отображения в интерфейсе."""
+ return self.__str__()
+
+
+class WithRemoteSourceMeta(InheritedModelMetaclass):
+
+ def __new__(cls, name, bases, attrs, **kwargs):
+ source_group = attrs.get('source_group')
+
+ if source_group:
+ # Прописываем choices для источников.
+ src_alias = copy(bases[-1].src_alias.field)
+ src_alias.choices = source_group.get_enum().choices
+ attrs['src_alias'] = src_alias
+
+ cls_new = super().__new__(cls, name, bases, attrs, **kwargs)
+
+ if source_group:
+ # Прописываем уникальность.
+ cls_new._meta.unique_together = (('src_alias', 'src_id'),)
+
+ return cls_new
+
+
+class WithRemoteSource(RealmBaseModel, metaclass=WithRemoteSourceMeta):
+ """Примесь для моделей, умеющих хранить данные, полученные из внешних источников."""
+
+ source_group: type[RemoteSource] = None
+
+ # Ограничения (choices) выбора источников проставляются в метаклассе.
+ src_alias = models.CharField('Идентификатор источника', max_length=20, null=True, blank=True)
+ src_id = models.CharField('ID в источнике', max_length=50, null=True, blank=True)
+
+ url = models.URLField('Страница в сети', null=True, blank=True)
+
+ class Meta:
+
+ abstract = True
+ unique_together = ('src_alias', 'src_id') # Не наследуется https://code.djangoproject.com/ticket/16732
+
+ def extract_page_info(self):
+ """Возвращает информацию о странице, расположенной
+ по указанному в объекте URL, либо None.
+
+ """
+ source = self.source_group.get_source(self.src_alias)
+ info = source.get_page_info(self.url)
+ return info
+
+ @classmethod
+ def spawn_object(cls, item_data: dict, *, source: RemoteSource):
+ """Конструирует объект модели, наполняя данными из словаря.
+
+ :param item_data:
+ :param source: Объект источника.
+
+ """
+ obj = cls(**item_data)
+ obj.src_alias = source.alias
+ obj.status = obj._status_backup = cls.Status.PUBLISHED
+
+ return obj
+
+ @classmethod
+ def fetch_items(cls):
+ """Добывает данные из источника и складирует их."""
+
+ for source in cls.source_group.get_sources().values():
+
+ source_obj = source()
+ items = source_obj.fetch_list()
+
+ if not items:
+ return
+
+ seen = set(cls.objects.filter(
+
+ src_alias=source.alias,
+ src_id__in=[item_data['src_id'] for item_data in items],
+
+ ).values_list('src_id', flat=True))
+
+ for item_data in items:
+
+ if item_data['src_id'] in seen:
+ continue
+
+ obj = cls.spawn_object(item_data, source=source_obj)
+
+ # По одному, чтобы отработала логика save().
+ obj.save(notify_published=False)
diff --git a/pythonz/apps/generics/realms.py b/pythonz/apps/generics/realms.py
new file mode 100644
index 00000000..c30166d1
--- /dev/null
+++ b/pythonz/apps/generics/realms.py
@@ -0,0 +1,398 @@
+from collections.abc import Callable
+
+from django.contrib.sitemaps import GenericSitemap
+from django.db.models import QuerySet
+from django.urls import re_path, reverse
+from sitetree.models import TreeItemBase
+from sitetree.utils import item
+from yaturbo import YandexTurboFeed
+
+from ..integration.utils import get_thumb_url
+from ..utils import get_logger
+from .forms import CommonEntityForm
+from .views import AddView, DetailsView, EditView, ListingView, RealmView, TagsView
+
+if False: # pragma: nocover
+ from .models import RealmBaseModel # noqa
+
+
+LOGGER = get_logger('realms')
+SYNDICATION_URL_MARKER = 'feed'
+SYNDICATION_ITEMS_LIMIT = 15
+
+
+class RealmBase:
+ """Базовый класс области (книга, видео и пр)"""
+
+ model: type['RealmBaseModel'] = None
+ """Класс модели, связанный с областью"""
+
+ form: type[CommonEntityForm] = None
+ """Форма, связанная с областью."""
+
+ icon: str = 'icon'
+ """Иконка, символизирующая область."""
+
+ name: str = None
+ """Имя области. Ед.ч."""
+ name_plural: str = None
+ """Имя области. Мн. ч."""
+
+ allowed_views: tuple[str, ...] = ('listing', 'details', 'tags', 'add', 'edit')
+ """Имена доступных представлений."""
+
+ show_on_main: bool = True
+ """Следует ли отображать на главной странице."""
+ show_on_top: bool = True
+ """Следует ли отображать в верхнем меню."""
+
+ sitetree_items: TreeItemBase = None
+ """Кеш элементов древа сайта для данной области."""
+
+ ready_for_digest: bool = True
+ """Указывает на готовность области попасть в еженедельный сводки."""
+
+ syndication_enabled: bool = True
+ """Указание на то, доступна ли синдикация."""
+ syndication_url: str = None
+ """URL синдикации."""
+ syndication_feed: YandexTurboFeed = None
+ """Кеш синдикации."""
+
+ sitemap_enabled: bool = True
+ """Указание на то, включена ли для области карта сайта."""
+ sitemap: GenericSitemap = None
+ """Кеш карты сайта для данной области."""
+ sitemap_date_field: str = 'time_modified'
+ """Поле даты в моделях области (для карты сайта)."""
+ sitemap_changefreq: str = 'daily'
+ """Предполагаемая периодичность обновления данных (для карты сайта)."""
+
+ txt_form_add: str = 'Добавить элемент'
+ txt_form_edit: str = 'Редактировать элемент'
+
+ # Представление списка.
+ view_listing = None
+ view_listing_base_class: type[RealmView] = ListingView
+ view_listing_url: str = r'^$'
+ view_listing_title: str = None
+ view_listing_description: str = ''
+ view_listing_keywords: str = ''
+
+ # Представление с детальной информацией.
+ view_details = None
+ view_details_base_class: type[RealmView] = DetailsView
+ view_details_url: str = r'^(?P\d+)/$'
+ view_details_slug_url: str = r'^named/(?P[0-9A-z\.-]+)/$'
+
+ # Представление для добавления нового элемента.
+ view_add = None
+ view_add_base_class: type[RealmView] = AddView
+ view_add_url: str = r'^add/$'
+
+ # Представление для редактирования.
+ view_edit = None
+ view_edit_base_class: type[RealmView] = EditView
+ view_edit_url: str = r'^edit/(?P\d+)/$'
+
+ # Представление с разбивкой элементов по категориям.
+ view_tags = None
+ view_tags_base_class: type[RealmView] = TagsView
+ view_tags_url: str = r'^tags/(?P\d+)/$'
+
+ @classmethod
+ def init(cls):
+ """Инициализатор обсласти. Наследники могут использовать для своих нужд."""
+
+ @classmethod
+ def is_allowed_edit(cls) -> bool:
+ """Возвращает флаг, указывающий на возможность редактирования объектов
+ в данной области.
+
+ """
+ return 'edit' in cls.allowed_views
+
+ @classmethod
+ def _get_syndication_feed(
+ cls,
+ title: str,
+ description: str,
+ func_link: Callable,
+ func_items: Callable,
+ cls_name: str
+ ) -> YandexTurboFeed:
+
+ type_dict = {
+ 'title': title,
+ 'description': f'PYTHONZ. {description}',
+ 'item_enclosure_mime_type': 'image/png',
+ 'item_enclosure_length': 50000,
+ 'item_enclosure_url': lambda self, item: (
+ get_thumb_url(item.realm, item.cover, 100, 131, absolute_url=True)
+ if hasattr(item, 'cover') else ''
+ ),
+ 'link': func_link,
+ 'items': func_items,
+ 'item_title': lambda self, item: item.title,
+ 'item_pubdate': lambda self, item: item.time_published,
+ 'item_link': lambda self, item: item.get_absolute_url(),
+ 'item_guid': lambda self, item: f'{item.realm.name}_{item.pk}',
+ 'item_guid_is_permalink': False,
+ 'item_description': lambda self, item: item.description,
+ 'item_turbo': lambda self, item: item.turbo_content,
+ }
+
+ feed_cls: YandexTurboFeed = type(f'{cls_name}Syndication', (YandexTurboFeed,), type_dict)()
+ feed_cls.turbo_sanitize = True
+
+ return feed_cls
+
+ @classmethod
+ def get_syndication_feed(cls) -> YandexTurboFeed:
+ """Возвращает объект потока синдикации (RSS)."""
+
+ def get_items(self) -> QuerySet:
+ items = []
+
+ try:
+ items = cls.model.get_actual()[:SYNDICATION_ITEMS_LIMIT]
+
+ except AttributeError:
+ # todo Затычка для модели Categories. Убрать фиктивный RSS при случае.
+ pass
+
+ return items
+
+ if cls.syndication_feed is None:
+
+ title = cls.model._meta.verbose_name_plural
+
+ cls.syndication_feed = cls._get_syndication_feed(
+ title=title,
+ description=f'Материалы в разделе «{title}»',
+ func_link=lambda self: reverse(cls.get_listing_urlname()),
+ func_items=get_items,
+ cls_name=cls.name
+ )
+
+ return cls.syndication_feed
+
+ @classmethod
+ def get_sitemap(cls) -> GenericSitemap:
+ """Возвращает объект карты сайта (sitemap)."""
+
+ if cls.sitemap is None:
+ settings = {
+ 'queryset': cls.model.get_actual(),
+ 'date_field': cls.sitemap_date_field,
+ }
+ cls.sitemap = GenericSitemap(settings, priority=0.5, changefreq=cls.sitemap_changefreq)
+
+ return cls.sitemap
+
+ @classmethod
+ def get_listing_urlname(cls) -> str:
+ """Возвращает название URL страницы со списком объектов."""
+ _tmp, realm_name_plural = cls.get_names()
+
+ return f'{realm_name_plural}:listing'
+
+ @classmethod
+ def get_details_urlname(cls, *, slugged: bool = False) -> str:
+ """Возвращает название URL страницы с детальной информацией об объекте.
+
+ :param slugged Следует ли вернуть название для URL человекопонятного
+ (см. CommonEntityModel.slug_auto).
+
+ """
+ _tmp, realm_name_plural = cls.get_names()
+
+ name = f'{realm_name_plural}:details'
+
+ if slugged:
+ name += '_slug'
+
+ return name
+
+ @classmethod
+ def get_sitetree_details_item(cls) -> list[TreeItemBase]:
+ """Возвращает элемент древа сайта, указывающий на страницу с детальной информацией об объекте."""
+
+ realm_name, realm_name_plural = cls.get_names()
+
+ children = []
+
+ if 'edit' in cls.allowed_views:
+ children.append(cls.get_sitetree_edit_item())
+
+ details_urlname = cls.get_details_urlname()
+
+ def get_item(urlname, id_attr='id'):
+ return item(
+ '{{ %s }}' % realm_name, # noqa: UP031
+ f'{urlname} {realm_name}.{id_attr}', # Например books:details book.id
+ children=children,
+ in_menu=False,
+ in_sitetree=False
+ )
+
+ items = [get_item(details_urlname)]
+
+ if getattr(cls.model, 'slug_pick', False):
+ items.append(get_item(cls.get_details_urlname(slugged=True), id_attr='slug'))
+
+ return items
+
+ @classmethod
+ def get_edit_urlname(cls) -> str:
+ """Возвращает название URL страницы редактирования объекта."""
+ _tmp, realm_name_plural = cls.get_names()
+
+ return f'{realm_name_plural}:edit'
+
+ @classmethod
+ def get_sitetree_edit_item(cls) -> TreeItemBase:
+ """Возвращает элемент древа сайта, указывающий на страницу редактирования объекта."""
+ realm_name, _tmp = cls.get_names()
+
+ return item(
+ cls.txt_form_edit, f'{cls.get_edit_urlname()} {realm_name}.id',
+ in_menu=False, in_sitetree=False, access_loggedin=True)
+
+ @classmethod
+ def get_add_urlname(cls) -> str:
+ """Возвращает название URL страницы добавления объекта."""
+ _tmp, realm_name_plural = cls.get_names()
+
+ return f'{realm_name_plural}:add'
+
+ @classmethod
+ def get_sitetree_add_item(cls) -> TreeItemBase:
+ """Возвращает элемент древа сайта, указывающий на страницу добавления объекта."""
+
+ tree_item = item(cls.txt_form_add, cls.get_add_urlname(), access_loggedin=True)
+ tree_item.show_on_top = True
+
+ return tree_item
+
+ @classmethod
+ def get_tags_urlname(cls) -> str:
+ """Возвращает название URL страницы со списком объектов в определённой категории."""
+ _tmp, realm_name_plural = cls.get_names()
+
+ return f'{realm_name_plural}:tags'
+
+ @classmethod
+ def get_sitetree_tags_item(cls) -> TreeItemBase:
+ """Возвращает элемент древа сайта, указывающий на страницу разбивки объектов по метке (категории)."""
+ return item(
+ '{{ category.title }} - %s' % cls.model._meta.verbose_name, # noqa: UP031
+ url=f'{cls.get_tags_urlname()} category.id',
+ in_menu=False, in_sitetree=False)
+
+ @classmethod
+ def get_sitetree_items(cls) -> TreeItemBase:
+ """Возвращает элементы древа сайта."""
+
+ sitetree_items = cls.sitetree_items
+
+ if sitetree_items is None:
+ children = []
+
+ for view_name in cls.allowed_views:
+
+ if view_name not in ('listing', 'edit'):
+ items = getattr(cls, f'get_sitetree_{view_name}_item')()
+
+ if not isinstance(items, list):
+ items = [items]
+
+ children.extend(items)
+
+ sitetree_items = item(
+ cls.view_listing_title or str(cls.model._meta.verbose_name_plural),
+ cls.get_listing_urlname(),
+ description=cls.view_listing_description,
+ children=children,
+ )
+ if 'listing' not in cls.allowed_views:
+ sitetree_items.inmenu = False
+ sitetree_items.insitetree = False
+ sitetree_items.inbreadcrumbs = False
+
+ sitetree_items.show_on_top = cls.show_on_top
+ cls.sitetree_items = sitetree_items
+
+ return sitetree_items
+
+ @classmethod
+ def get_names(cls) -> tuple[str, str]:
+ """Возвращает кортеж с именами области в ед. и мн. числах."""
+
+ if cls.name is None:
+ cls.name = cls.__name__.lower().replace('realm', '')
+
+ if cls.name_plural is None:
+ cls.name_plural = f'{cls.name}s'
+
+ return cls.name, cls.name_plural
+
+ @classmethod
+ def get_view(cls, name: str) -> RealmView:
+ """Формирует и возвращает объект представления.
+
+ :param name:
+
+ """
+ view_attr_name = f'view_{name}'
+ view = getattr(cls, view_attr_name)
+
+ if view is None:
+ realm_name, _ = cls.get_names()
+ base_view_class = getattr(cls, f'view_{name}_base_class')
+
+ class_dict = {
+ 'realm': cls,
+ 'name': name
+ }
+
+ view = type(f'{realm_name.capitalize()}{name.capitalize()}View', (base_view_class,), class_dict)
+ setattr(cls, view_attr_name, view)
+
+ return view
+
+ @classmethod
+ def get_syndication_url(cls) -> str:
+ """Возвращает URL потока синдикации (RSS)."""
+
+ if cls.syndication_url is None:
+ cls.syndication_url = reverse(f'{cls.name_plural}:syndication')
+
+ return cls.syndication_url
+
+ @classmethod
+ def get_urls(cls) -> list:
+ """Вовзвращает набор URL, актуальных для этой области."""
+ views = []
+
+ def add_view(view_name: str, url_name: str = None):
+
+ if url_name is None:
+ url_name = view_name
+
+ views.append(
+ re_path(getattr(cls, f'view_{url_name}_url'), cls.get_view(view_name).as_view(), name=url_name)
+ )
+
+ for view_name in cls.allowed_views:
+ add_view(view_name)
+
+ if view_name == 'details':
+ add_view(view_name, 'details_slug')
+
+ if cls.syndication_enabled:
+ views.append(re_path(fr'^{SYNDICATION_URL_MARKER}/$', cls.get_syndication_feed(), name='syndication'))
+
+ _, realm_name_plural = cls.get_names()
+
+ return [re_path(fr'^{realm_name_plural}/', (views, realm_name_plural, realm_name_plural))]
diff --git a/pythonz/apps/generics/views.py b/pythonz/apps/generics/views.py
new file mode 100644
index 00000000..7db9b12d
--- /dev/null
+++ b/pythonz/apps/generics/views.py
@@ -0,0 +1,562 @@
+from collections.abc import Callable
+from datetime import datetime
+
+from django.contrib.auth.decorators import login_required
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import PermissionDenied
+from django.core.paginator import EmptyPage, Page, Paginator
+from django.db.models import QuerySet
+from django.http import Http404, HttpRequest
+from django.shortcuts import HttpResponse, get_object_or_404, redirect, render
+from django.utils.decorators import method_decorator
+from django.views.decorators.http import condition
+from django.views.generic.base import View
+from siteajax.toolbox import ajax_dispatch
+from sitecats.models import Tie
+from sitecats.toolbox import get_category_aliases_under
+
+from ..exceptions import PythonzException, RedirectRequired
+from ..integration.partners import get_partner_links
+from ..models import Article, Category, Community, Discussion, Event, ModelWithCategory, ModelWithDiscussions, User
+from ..utils import message_error, message_info, message_success, message_warning
+from .models import ModelWithCompiledText, RealmBaseModel
+
+if False: # pragma: nocover
+ from .realms import RealmBase # noqa
+
+
+class HttpRequest(HttpRequest):
+
+ user: User
+ user_id: int
+
+
+class RealmView(View):
+ """Базовое представление для представлений областей (сущностей)."""
+
+ realm: 'RealmBase' = None # Во время исполнения будет содержать ссылку на объект области Realm
+ name: str = None # Во время исполнения будет содержать алиас этого представления (н.п. edit).
+
+ func_etag: Callable = None
+ """Может указывать на метод, реализующий возвращение ETag."""
+ func_last_mod: Callable = None
+ """Может указывать на метод, возвращающий дату Last-Modified."""
+
+ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+
+ @condition(self.func_etag, self.func_last_mod)
+ def conditional_dispatch(request, *args, **kwargs):
+ return super(RealmView, self).dispatch(request, *args, **kwargs)
+
+ if request.method.lower() == 'get':
+ # Кеширование GET-выдач.
+ return conditional_dispatch(request, *args, **kwargs)
+
+ return super().dispatch(request, *args, **kwargs)
+
+ def check_view_permissions(self, request: HttpRequest, item: RealmBaseModel):
+ """Производит провердку возможности доступа к просмотру страницы.
+ Возбуждает исключения, в случае ошибкидоступа.
+
+ :param request:
+ :param item:
+
+ """
+ if not request.user.is_superuser:
+
+ if item.is_deleted:
+ # Запрещаем доступ к удалённым.
+ raise Http404
+
+ elif item.is_draft and hasattr(item, 'submitter') and item.submitter != request.user:
+ # Закрываем доступ к чужим черновикам.
+ raise PermissionDenied
+
+ def check_edit_permissions(self, request: HttpRequest, item: RealmBaseModel):
+ """Производит проверку прав пользователя для доступа к редактированию объекта.
+
+ :param request:
+ :param item:
+
+ """
+ realm = self.realm
+
+ if not realm.is_allowed_edit(): # Область не поддерживает редактирования.
+ raise PermissionDenied
+
+ user = request.user
+
+ if not user.is_authenticated: # Неавторизованные пользователи не могут ничего.
+ raise PermissionDenied
+
+ if user.is_superuser:
+ return
+
+ try:
+ edit_by_owner = (request.user_id == item.submitter_id)
+
+ except AttributeError:
+ edit_by_owner = (user == item) # Модель User
+
+ if edit_by_owner:
+ return
+
+ if not realm.model.allow_edit_anybody:
+ raise PermissionDenied
+
+ if item.is_published and not realm.model.allow_edit_published:
+
+ message_warning(
+ request,
+ 'Этот материал уже прошёл модерацию и был опубликован. '
+ 'На данный момент в проекте запрещено редактирование опубликованных материалов.')
+
+ raise PermissionDenied
+
+ @classmethod
+ def get_template_path(cls, name: str = None) -> str:
+ """Возвращает путь к шаблону страницы представления для данной области."""
+
+ if name is None:
+ name = cls.name
+
+ return f'realms/{cls.realm.name_plural}/{name}.html'
+
+ def render(self, request: HttpRequest, data_dict: dict) -> HttpResponse:
+ """Компилирует страницу представления.
+
+ :param request:
+ :param data_dict:
+
+ """
+ data_dict.update({
+ 'realm': self.realm,
+ 'view': self
+ })
+ return render(request, f'view_{self.__class__.name}.html', data_dict)
+
+ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Запросы POST перенаправляются на обработчик GET.
+
+ :param request:
+ :param args:
+ :param kwargs:
+
+ """
+ return self.get(request, *args, **kwargs)
+
+ def update_context(self, context: dict, request: HttpRequest):
+ """Используется для дополнения контекста шаблона данными.
+
+ :param context:
+ :param request:
+
+ """
+
+ def get_object_or_404(self, obj_id: int) -> RealmBaseModel:
+ """Реализует механизм обнаружения объекта нужного типа для области,
+ к которой привязано это представление.
+
+ :param obj_id:
+
+ """
+ model = self.realm.model
+
+ q = model.objects
+
+ related = getattr(model, 'details_related', [])
+
+ if related:
+ q = q.select_related(*related)
+
+ if 'named/' in self.request.path:
+ return get_object_or_404(q, slug=obj_id)
+
+ return get_object_or_404(q, pk=obj_id)
+
+
+class ListingView(RealmView):
+ """Список объектов."""
+
+ def get_last_modified(self, *args, **kwargs) -> datetime | None:
+ """Возвращает Last-Modified для списка сущностей."""
+
+ field = 'time_published'
+ objects = self.get_paginator_objects()
+
+ if isinstance(objects, list):
+ # Если пришёл список, значит кеширование должно
+ # было быть реализовано при его составлении.
+ return
+
+ last = objects.values(field).order_by(field).last()
+
+ return last and last[field]
+
+ func_last_mod = get_last_modified
+
+ def get_paginator_objects(self) -> QuerySet:
+ """Возвращает объекты для страницы при постраницчной навигации."""
+ return self.realm.model.get_paginator_objects()
+
+ def get_most_voted_objects(self) -> QuerySet:
+ """Возвращает объекты, за которые было отдано больше всего голосов."""
+ return self.realm.model.get_most_voted_objects()
+
+ @classmethod
+ def extend_paginator(cls, page_items: Page):
+ """Дополняет объект постраничной навигации.
+
+ :param page_items:
+
+ """
+ max_pages_before_after = 10
+ page = page_items.number
+
+ min_page_before = page-max_pages_before_after
+ if min_page_before < 1:
+ min_page_before = 0
+
+ max_page_after = page+max_pages_before_after
+ if max_page_after > page_items.paginator.num_pages:
+ max_page_after = page_items.paginator.num_pages
+
+ page_items.before_current = reversed(range(page-1, min_page_before, -1))
+ page_items.after_current = range(page+1, max_page_after+1)
+
+ def get_paginator_per_page(self, request: HttpRequest) -> int:
+ return self.realm.model.items_per_page
+
+ @classmethod
+ def get_categories(cls) -> list[Category]:
+
+ model = cls.realm.model
+
+ if not issubclass(model, ModelWithCategory):
+ return []
+
+ content_type = ContentType.objects.get_for_model(model, for_concrete_model=False)
+
+ categories = [
+ Category(id=category_id, title=category_title)
+ for category_id, category_title in
+ Tie.objects.filter(content_type=content_type).
+ values_list('category_id', 'category__title').
+ distinct()[:30]
+ ]
+
+ get_url = model.get_category_absolute_url
+
+ for category in categories:
+ category.note = get_url(category)
+
+ return categories
+
+ def get(self, request: HttpRequest, category_id: int = None) -> HttpResponse:
+
+ try:
+ page = int(request.GET.get('p'))
+
+ except (TypeError, ValueError):
+ page = 1
+
+ paginator = Paginator(self.get_paginator_objects(), self.get_paginator_per_page(request))
+
+ try:
+ page_items = paginator.page(page)
+
+ except EmptyPage:
+ page_items = paginator.page(1)
+
+ self.extend_paginator(page_items)
+
+ category = None
+ if category_id is not None:
+ category = Category.objects.get(pk=category_id)
+
+ context = {
+ self.realm.name_plural: page_items,
+ 'items': page_items,
+ 'category': category,
+ 'get_categories': self.get_categories,
+ 'items_most_voted': self.get_most_voted_objects()
+ }
+
+ self.update_context(context, request)
+
+ return self.render(request, context)
+
+
+class DetailsView(RealmView):
+ """Детальная информация об объекте."""
+
+ def _attach_support_data(self, item: RealmBaseModel, request: HttpRequest):
+ """Цепляет к объекту данные о поданном за него голосе пользователя.
+
+ :param item:
+ :param request:
+
+ """
+ item.my_support = item.is_supported_by(request.user)
+
+ def _attach_bookmark_data(self, item: RealmBaseModel, request: HttpRequest):
+ """Цепляет к объекту данные о его занесении его пользователем в избранное.
+
+ :param item:
+ :param request:
+
+ """
+ item.is_bookmarked = item.is_bookmarked_by(request.user)
+
+ def _attach_data(self, item: RealmBaseModel, request: HttpRequest):
+ """Цепляет базовый набор данный к объекту.
+
+ :param item:
+ :param request:
+
+ """
+ self._attach_bookmark_data(item, request)
+ self._attach_support_data(item, request)
+
+ @method_decorator(login_required)
+ def toggle_bookmark(self, request: HttpRequest, obj_id: int) -> HttpResponse:
+ """Обслуживает ajax-запрос. Реализует занесение/изъятие объекта в/из избранного..
+
+ :param request:
+ :param obj_id:
+
+ """
+ item = self.get_object(request=request, obj_id=obj_id)
+ action = int(request.POST.get('action', 0))
+
+ if action == 1:
+ item.set_bookmark(self.request.user)
+
+ elif action == 0:
+ item.remove_bookmark(self.request.user)
+
+ self._attach_bookmark_data(item, self.request)
+
+ return render(self.request, 'sub/box_bookmark.html', {'item': item})
+
+ @method_decorator(login_required)
+ def set_rate(self, request: HttpRequest, obj_id: int) -> HttpResponse:
+ """Обслуживает ajax-запрос. Реализует оценку объекта.
+
+ :param request:
+ :param obj_id:
+
+ """
+ item = self.get_object(request=request, obj_id=obj_id)
+
+ if self._is_rating_allowed(request, item):
+ action = int(request.POST.get('action', 0))
+
+ if action == 1:
+ item.set_support(request.user)
+
+ elif action == 0:
+ item.remove_support(request.user)
+
+ self._attach_support_data(item, request)
+
+ return render(request, 'sub/box_rating.html', {'item': item})
+
+ @classmethod
+ def _is_rating_allowed(cls, request: HttpRequest, item: RealmBaseModel) -> bool:
+ """Возвращает флаг, указывающий на то, можно
+ ли рекомендовать данную сущность (материал).
+
+ :param request:
+ :param item:
+
+ """
+ return request.user != item # Пользователи не могут рекомендовать себя %)
+
+ def list_partner_links(self, request: HttpRequest, obj_id: int) -> HttpResponse:
+ """Обслуживает ajax-запрос. Реализует получение блока с партнёрскими ссылками.
+
+ :param request:
+ :param obj_id:
+
+ """
+ item = self.get_object(request=request, obj_id=obj_id)
+ return render(request, self.get_template_path('partner_links'), get_partner_links(self.realm, item))
+
+ def get_object(self, *, request: HttpRequest, obj_id: int):
+
+ item = self.get_object_or_404(obj_id)
+
+ self.check_view_permissions(request, item)
+
+ item.has_discussions = False
+
+ if isinstance(item, ModelWithDiscussions):
+ item.has_discussions = True
+
+ return item
+
+ @ajax_dispatch({
+ 'list-partner-links': list_partner_links,
+ 'set-rate': set_rate,
+ 'toggle-bookmark': toggle_bookmark,
+ })
+ def get(self, request: HttpRequest, obj_id: int) -> HttpResponse:
+
+ item = self.get_object(request=request, obj_id=obj_id)
+
+ try:
+ self._attach_data(item, request)
+
+ except RedirectRequired:
+ return redirect(item, permanent=True)
+
+ item_rating_allowed = self._is_rating_allowed(request, item)
+
+ try:
+ self.check_edit_permissions(request, item)
+ item_edit_allowed = True
+
+ except PermissionDenied:
+ item_edit_allowed = False
+
+ if isinstance(item, ModelWithCategory):
+ item.has_categories = True
+ item.set_category_lists_init_kwargs({'show_title': True, 'cat_html_class': 'label label-default'})
+
+ # Нарочно передаём item под двумя разными именами.
+ # Требуется для упрощения наследования шаблонов.
+ context = {
+ self.realm.name: item,
+ 'item': item,
+ 'item_edit_allowed': item_edit_allowed,
+ 'item_rating_allowed': item_rating_allowed
+ }
+
+ self.update_context(context, request)
+
+ return self.render(request, context)
+
+
+class TagsView(ListingView):
+ """Список меток (категорий) для объекта."""
+
+ def get_paginator_objects(self) -> QuerySet:
+ return self.realm.model.get_objects_in_category(self.kwargs['category_id'])
+
+ def get_most_voted_objects(self) -> QuerySet:
+ return self.realm.model.get_most_voted_objects_in_category(self.kwargs['category_id'])
+
+
+class EditView(RealmView):
+ """Редактирование (и добавление) объекта."""
+
+ @method_decorator(login_required) # На страницах редактирования требуется атворизация.
+ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ return super().dispatch(request, *args, **kwargs)
+
+ def preview_rst(self, request: HttpRequest, obj_id: int | None = None) -> HttpResponse:
+ """Обслуживает ajax-запрос. Обрабатывает запрос на предварительный просмотр текста в формате rst.
+
+ :param request:
+
+ """
+ return HttpResponse(ModelWithCompiledText.compile_text(request.POST.get('text_src', '')))
+
+ @ajax_dispatch({
+ 'preview-rst': preview_rst,
+ })
+ def get(self, request: HttpRequest, obj_id: int | None = None) -> HttpResponse:
+
+ item = None
+
+ if obj_id is not None:
+ item = self.get_object_or_404(obj_id)
+
+ data = None
+ if 'pythonz_form' in request.POST:
+ data = request.POST
+
+ if item is not None:
+ self.check_edit_permissions(request, item)
+
+ form = self.realm.form(
+ data,
+ request=request,
+ src='POST',
+ instance=item,
+ files=request.FILES or None,
+ id='edit_form',
+ user=request.user,
+ )
+
+ if isinstance(item, ModelWithCategory):
+
+ item.has_categories = True
+ category_handled = item.enable_category_lists_editor(
+ request,
+ additional_parents_aliases=get_category_aliases_under(),
+ handler_init_kwargs={'error_messages_extra_tags': 'alert alert-danger'},
+ lists_init_kwargs={'show_title': True, 'cat_html_class': 'label label-default'},
+ editor_init_kwargs={
+ 'allow_add': True, 'allow_new': request.user.is_superuser, 'allow_remove': True,
+ 'category_separator': ', '
+ }
+ )
+
+ if category_handled: # Добавилась категория, перенаправим на эту же страницу.
+ return redirect(self.realm.get_edit_urlname(), item.id, permanent=True)
+
+ show_moderation_hint = self.realm.model not in (User, Article, Discussion, Community, Event)
+
+ if data is None:
+
+ if show_moderation_hint:
+ message_warning(
+ request,
+ 'Обратите внимание, что на данном этапе развития проекта добавляемые '
+ 'материалы проходят модерацию, прежде чем появиться на сайте.'
+ )
+
+ if item is None:
+ redirector = lambda: redirect(item, permanent=True) # noqa: E731
+
+ else:
+ redirector = lambda: redirect(self.realm.get_edit_urlname(), item.id, permanent=True) # noqa: E731
+
+ if form.is_valid():
+
+ try:
+ if item is None:
+
+ form.instance.submitter = request.user
+ item = form.save()
+ message_success(request, 'Объект добавлен.')
+
+ if show_moderation_hint:
+ message_info(request, 'Данный объект появится на сайте после модерации.')
+
+ else:
+ form.instance.last_editor = request.user
+ form.save()
+
+ message_success(request, 'Данные сохранены.')
+
+ return redirector()
+
+ except PythonzException as e:
+ message_error(request, e.message)
+
+ context = {'form': form, self.realm.name: item, 'item': item}
+
+ try:
+ self.update_context(context, request)
+
+ except RedirectRequired:
+ return redirector()
+
+ return self.render(request, context)
+
+
+class AddView(EditView):
+ """Добавление объекта."""
diff --git a/apps/templatetags/__init__.py b/pythonz/apps/integration/__init__.py
similarity index 100%
rename from apps/templatetags/__init__.py
rename to pythonz/apps/integration/__init__.py
diff --git a/pythonz/apps/integration/base.py b/pythonz/apps/integration/base.py
new file mode 100644
index 00000000..8ab80ec4
--- /dev/null
+++ b/pythonz/apps/integration/base.py
@@ -0,0 +1,115 @@
+from collections import defaultdict
+from enum import unique
+
+from django.db import models
+
+from .utils import PageInfo, get_from_url, get_page_info, run_threads
+
+
+class RemoteSource:
+ """База для удалённых источников данных."""
+
+ realm: str = ''
+ """Область, к которой привязан источник."""
+
+ active: bool = True
+ """Показатель активности источника."""
+
+ alias: str = ''
+ """Псевдоним источника (краткий лат.)."""
+
+ title: str = ''
+ """Название источника."""
+
+ registry: dict = defaultdict(dict)
+ """Зарегистрированные источники."""
+
+ def __init_subclass__(cls):
+ super().__init_subclass__()
+
+ alias = cls.alias
+
+ if alias:
+ cls.registry[cls.realm][alias] = cls
+
+ @classmethod
+ def get_sources(cls) -> dict[str, type['RemoteSource']]:
+ """Возвращает словарь с источниками, зарегистрированными для области."""
+ return cls.registry[cls.realm]
+
+ @classmethod
+ def get_source(cls, alias: str) -> type['RemoteSource'] | None:
+ """Возвращает класс источника по псевдониму.
+
+ :param alias: Псевдоним источника.
+
+ """
+ return cls.get_sources().get(alias)
+
+ @classmethod
+ def get_enum(cls) -> type[models.TextChoices]:
+ """Возвращает перечисление источников для модели."""
+
+ enum = unique(models.TextChoices('Source', [
+ (alias, (alias, source_cls.title))
+ for alias, source_cls in cls.get_sources().items()
+ ]))
+
+ return enum
+
+ def request(self, url: str) -> str:
+ """Отправляет запрос на указанный URL.
+
+ :param url:
+
+ """
+ response = get_from_url(url)
+
+ return response.text
+
+ def fetch_list(self) -> list[dict]:
+ """Возвращает словарь с данными записей, полученных из внешнего
+ источника.
+
+ """
+ raise NotImplementedError # pragma: nocover
+
+ @classmethod
+ def get_page_info(cls, url: str) -> PageInfo | None:
+ """Возвращает информацию о странице, расположенной
+ по указанному адресу, либо None.
+
+ :param url:
+
+ """
+ return get_page_info(url)
+
+ @classmethod
+ def get_pages_info(cls, urls: set[str], *, thread_num: int = None) -> dict[str, PageInfo | None]:
+ """Возвращает информацию о страницах, расположенных
+ по указанным адресам. Отправляет запросы в нитях.
+
+ :param urls:
+
+ :param thread_num: Количество нитей для забора данных.
+ Если не указано, о будет создано нитей по количеству URL,
+ но не более определённого числа.
+
+ """
+ return run_threads(urls, cls.get_page_info, thread_num=thread_num)
+
+ @classmethod
+ def contribute_page_info(cls, results: list[dict]):
+ """Дополняет указанные словари результатов ключем __page_info,
+ в котором будут указаны данные страниц, упомянутых в ключах `url`.
+
+ Внимание: изменяет исходный список.
+
+ :param results:
+
+ """
+ by_url = cls.get_pages_info({item['url'] for item in results})
+
+ for item in results:
+ page_info = by_url.get(item['url'])
+ item['__page_info'] = page_info or None
diff --git a/pythonz/apps/integration/events.py b/pythonz/apps/integration/events.py
new file mode 100644
index 00000000..35e0c641
--- /dev/null
+++ b/pythonz/apps/integration/events.py
@@ -0,0 +1,104 @@
+import re
+from datetime import timedelta
+from hashlib import md5
+
+from django.utils import timezone
+from icalendar_light.toolbox import Calendar
+
+from .base import RemoteSource
+
+
+class EventSource(RemoteSource):
+ """База для источников событий."""
+
+ realm: str = 'event'
+
+
+class GoogleCalendarSource(EventSource):
+ """Источник Google-календарь."""
+
+ group: str = ''
+ """Идентификатор группы гуглового календаря."""
+
+ days_skip: int = 7
+ """Если событие меньше, чем за указанное количество дней, оно не будет принято в рассчёт."""
+
+ days_forward: int = 30
+ """Количество дней, на которое требуется заглянуть вперёд в календаре."""
+
+ big_events: bool = False
+ """Указание на то, что все события группы масштабные."""
+
+ @classmethod
+ def construct_url(cls):
+ return f'https://www.google.com/calendar/ical/{cls.group}@group.calendar.google.com/public/basic.ics'
+
+ def compose_item(self, event: Calendar.Event) -> dict:
+
+ try:
+ url = re.findall(r'="([^"]+)"', event.description or '')[0]
+
+ except IndexError:
+ url = ''
+
+ dt_start = event.dt_start
+ place = event.location or ''
+ place_id = ''
+
+ if place:
+ place_id = md5(f'{place}'.encode()).hexdigest()
+
+ item = {
+ 'title': event.summary,
+ 'url': url,
+ 'src_id': md5(f'{event.uid}|{dt_start.date()}'.encode()).hexdigest(),
+ 'src_place_id': place_id,
+ 'src_place_name': place,
+ 'time_start': dt_start,
+ 'time_finish': event.dt_end,
+ 'big': self.big_events,
+ }
+
+ return item
+
+ def fetch_list(self) -> list[dict]:
+
+ results = []
+
+ compose = self.compose_item
+
+ contents = self.request(self.construct_url())
+
+ min_date = timezone.now() + timedelta(days=self.days_skip)
+ events = Calendar.iter_events_upcoming(contents.splitlines(), days_forward=self.days_forward)
+
+ for event in events:
+
+ if not event.status or event.status == 'CONFIRMED':
+
+ if event.dt_start < min_date:
+ continue
+
+ results.append(compose(event))
+
+ self.contribute_page_info(results)
+
+ return results
+
+
+class PyEvent(GoogleCalendarSource):
+ """Официальный календарь крупных событий."""
+
+ alias: str = 'pyglob'
+ title: str = 'Официальный календарь'
+ group: str = 'j7gov1cmnqr9tvg14k621j7t5c'
+
+ big_events: bool = True
+
+
+class PyUserGroupEvent(GoogleCalendarSource):
+ """Календарь групп пользователей."""
+
+ alias: str = 'pyuser'
+ title: str = 'Календарь сообществ'
+ group: str = '3haig2m9msslkpf2tn1h56nn9g'
diff --git a/pythonz/apps/integration/partners.py b/pythonz/apps/integration/partners.py
new file mode 100644
index 00000000..024c56fe
--- /dev/null
+++ b/pythonz/apps/integration/partners.py
@@ -0,0 +1,304 @@
+from decimal import Decimal, InvalidOperation
+from typing import TYPE_CHECKING, NamedTuple, Union
+
+from bs4 import BeautifulSoup
+from django.conf import settings
+from django.core.cache import cache
+from django.db.models import signals
+from django.utils.timezone import now
+from requests import Response
+from requests.exceptions import ConnectionError
+
+from .utils import get_from_url, make_soup, run_threads
+
+if TYPE_CHECKING:
+ from ..generics.models import RealmBaseModel
+ from ..generics.realms import RealmBase
+ from ..models import ModelWithPartnerLinks, PartnerLink
+
+_CACHE_TIMEOUT: int = 28800 # 8 часов
+
+
+class PartnerBase:
+ """Базовый класс для работы с партнёрскими сайтами."""
+
+ alias: str = None
+ title: str = None
+ link_mutator: str = None
+
+ registry: dict[str, 'PartnerBase'] = {}
+ """Реестр объектов партнёров."""
+
+ def __init__(self, partner_id: str):
+ self.partner_id = partner_id
+
+ def __init_subclass__(cls) -> None:
+ super().__init_subclass__()
+
+ alias = cls.alias
+ partner_id = settings.PARTNER_IDS.get(alias, '')
+ cls.registry[alias] = cls(partner_id)
+
+ def get_link_data(self, realm: 'RealmBase', link: 'PartnerLink') -> dict:
+ """Возвращает словарь с данными партнёрской ссылки.
+
+ :param realm:
+ :param link:
+
+ """
+ link_url = link.url
+
+ link_mutator = self.link_mutator.replace('{partner_id}', self.partner_id)
+
+ if '?' in link_url and link_mutator.startswith('?'):
+ link_mutator = link_mutator.replace('?', '&')
+
+ url = f'{link_url}{link_mutator}'
+
+ title = f'{realm.model.get_verbose_name()} на {self.title}'
+ description = link.description
+
+ if description:
+ title = f'{title} — {description}'
+
+ page_soup = self.get_page_soup(link_url)
+
+ if not page_soup:
+ return {}
+
+ price = self.get_price(
+ page_soup
+ ).lower().strip(' .').replace('руб', 'руб.').replace('₽', 'руб.').strip()
+
+ try:
+ Decimal(price)
+ price = f'{price} руб.'
+
+ except InvalidOperation:
+ pass
+
+ data = {
+ 'icon_url': f'https://favicon.yandex.net/favicon/{self.title}',
+ 'title': title,
+ 'url': url,
+ 'price': price,
+ 'time': now()
+ }
+ return data
+
+ @classmethod
+ def get_page(cls, url: str) -> Response:
+ return get_from_url(url, timeout=20)
+
+ @classmethod
+ def get_page_soup(cls, url: str) -> BeautifulSoup | None:
+
+ try:
+ page = cls.get_page(url)
+
+ except ConnectionError:
+ return
+
+ return make_soup(page.text)
+
+ @classmethod
+ def get_price(cls, page_soup: BeautifulSoup) -> str:
+ return ''
+
+
+class BooksRu(PartnerBase):
+ """Класс реализует работу по партнёрской программе сайта books.ru."""
+
+ alias: str = 'booksru'
+ title: str = 'books.ru'
+ link_mutator: str = '?partner={partner_id}'
+
+ @classmethod
+ def get_price(cls, page_soup: BeautifulSoup) -> str:
+
+ price = ''
+
+ if page_soup:
+ if matches := page_soup.select('h3.book-price'):
+ price = matches[0].text
+
+ return price
+
+
+class LitRes(PartnerBase):
+ """Класс реализует работу по партнёрской программе сайта litres.ru."""
+
+ alias: str = 'litres'
+ title: str = 'litres.ru'
+ link_mutator: str = '?lfrom={partner_id}'
+
+ @classmethod
+ def get_price(cls, page_soup: BeautifulSoup) -> str:
+
+ price = ''
+
+ if page_soup:
+ if matches := page_soup.findAll('meta', attrs={'itemprop': 'price'}):
+ price = matches[0].attrs['content']
+
+ return price
+
+
+class Ozon(PartnerBase):
+ """Класс реализует работу по партнёрской программе сайта ozon.ru."""
+
+ alias: str = 'ozon'
+ title: str = 'ozon.ru'
+ link_mutator: str = '?partner={partner_id}'
+
+ @classmethod
+ def get_price(cls, page_soup: BeautifulSoup) -> str:
+
+ price = ''
+
+ if page_soup:
+ if matches := page_soup.findAll('span', attrs={'itemprop': 'price', 'class': 'hidden'}):
+ price = matches[0].text
+
+ return price
+
+
+class Book24(PartnerBase):
+ """Класс реализует работу по партнёрской программе сайта book24.ru (бывший read.ru)."""
+
+ alias: str = 'book24'
+ title: str = 'book24.ru'
+ link_mutator: str = (
+ '?utm_source=affiliate&utm_medium=cpa&utm_campaign={partner_id}&'
+ 'utm_content=site&partnerId={partner_id}')
+
+ @classmethod
+ def get_price(cls, page_soup: BeautifulSoup) -> str:
+
+ price = ''
+
+ if page_soup:
+
+ if match := page_soup.find(itemprop='price'):
+ price = match.get('content', '').replace(' ', '')
+
+ return price
+
+
+class Bookvoed(PartnerBase):
+ """Класс реализует работу по партнёрской программе сайта bookvoed.ru."""
+
+ alias: str = 'bookvoed'
+ title: str = 'bookvoed.ru'
+ link_mutator: str = '?{partner_id}' # нет партнёрской программы
+
+ @classmethod
+ def get_price(cls, page_soup: BeautifulSoup) -> str:
+
+ price = ''
+
+ if page_soup:
+
+ if matches := page_soup.select('.product'):
+ attrs = matches[0].attrs
+ price = attrs['data-product-price-discounted']
+
+ return price
+
+
+class LabirintRu(PartnerBase):
+ """Класс реализует работу по партнёрской программе сайта labirint.ru."""
+
+ alias: str = 'labirint'
+ title: str = 'labirint.ru'
+ link_mutator: str = '?p={partner_id}'
+
+ @classmethod
+ def get_price(cls, page_soup: BeautifulSoup) -> str:
+
+ price = ''
+
+ if page_soup:
+ if matches := page_soup.select('.buying-price-val-number'):
+ price = matches[0].text
+
+ return price
+
+
+def get_cache_key(instance: 'RealmBaseModel') -> str:
+ """Возвращает ключ записи кэша для указанного экземпляра сущности.
+
+ :param instance:
+
+ """
+ return f'partner_links|{instance.__class__.__name__}|{instance.pk}'
+
+
+def init_partners_module():
+ """Инициализирует объекты известных партнёров и заносит их в реестр."""
+ from ..models import PartnerLink # noqa: PLC0415
+
+ def partner_links_cache_invalidate(*args, **kwargs):
+ """Сбрасывает кеш партнёрских ссылок при изменении данных
+ моделей ссылок или их удалении.
+
+ """
+ cache_key = get_cache_key(kwargs.get('instance').linked_object)
+ cache.delete(cache_key)
+
+ signals.post_save.connect(partner_links_cache_invalidate, sender=PartnerLink, weak=False)
+ signals.post_delete.connect(partner_links_cache_invalidate, sender=PartnerLink, weak=False)
+
+
+init_partners_module()
+
+
+def get_partners_choices() -> list[tuple[str, str]]:
+ """Возвращает варианты выбора известных партнёров для раскрывающихся списков."""
+ choices = [
+ (partner.alias, partner.title)
+ for partner in PartnerBase.registry.values()
+ ]
+ return choices
+
+
+def get_partner_links(realm: type['RealmBase'], item: Union['RealmBaseModel', 'ModelWithPartnerLinks']) -> dict:
+ """Возвращает словарь с данными по партнёрским ссылкам,
+ готовый для передачи в шаблон.
+
+ :param realm:
+ :param item:
+
+ """
+ cache_key = get_cache_key(item)
+ links_data = cache.get(cache_key)
+
+ class Task(NamedTuple):
+ link: str
+ realm: str
+ partner: str
+
+ def contribute_info(task: Task):
+ if data := task.partner.get_link_data(task.realm, task.link):
+ links_data.append(data)
+
+ if links_data is None:
+
+ links_data = []
+ get_partner = PartnerBase.registry.get
+ tasks = [
+ Task(
+ link=link,
+ realm=realm,
+ partner=partner
+ )
+ for link in item.partner_links.order_by('partner_alias', 'description').all()
+ if (partner := get_partner(link.partner_alias))
+ ]
+
+ if tasks:
+ run_threads(tasks, contribute_info)
+
+ cache.set(cache_key, links_data, _CACHE_TIMEOUT)
+
+ return {'links': links_data}
diff --git a/pythonz/apps/integration/peps.py b/pythonz/apps/integration/peps.py
new file mode 100644
index 00000000..342f5bac
--- /dev/null
+++ b/pythonz/apps/integration/peps.py
@@ -0,0 +1,318 @@
+import re
+from datetime import datetime
+from functools import partial
+from os.path import splitext
+from typing import TYPE_CHECKING, NamedTuple
+
+import requests
+from django.conf import settings
+from django.utils import timezone
+
+from ..signals import sig_send_generic_telegram
+from ..utils import PersonName, get_logger, sync_many_to_many
+
+if TYPE_CHECKING:
+ from ..models import PEP
+
+
+LOG = get_logger(__name__)
+
+KEYS_REQUIRED: list[str] = ['pep', 'title', 'status', 'type', 'author', 'created']
+KEYS_OPTIONAL: list[str] = ['python-version', 'superseded-by', 'replaces', 'requires']
+KEYS_ALL: list[str] = KEYS_REQUIRED + KEYS_OPTIONAL
+
+RE_VERSION = re.compile(r'(\d{1,2}.\d{1,2}(.\d{1,2})?)')
+RE_MAIL_TYPE1 = re.compile(r'([^<]+)<[^>]+>')
+RE_MAIL_TYPE2 = re.compile(r'[^(]+\(([^)]+)\)')
+
+
+class PepInfo(NamedTuple):
+ num: int
+ title: str
+ status: str
+ type: str
+ created: datetime
+ authors: list[str]
+ versions: list[str]
+ superseded: list[str]
+ replaces: list[str]
+ requires: list[str]
+
+
+def strip_mail(value: str) -> list[str]:
+ """Удаляет адреса эл. почты из строки author."""
+ names = []
+
+ for name in value.split(','):
+ name = name.strip()
+
+ matches = RE_MAIL_TYPE1.match(name)
+
+ if not matches:
+ matches = RE_MAIL_TYPE2.match(name)
+
+ if matches:
+ name = matches.group(1).strip()
+
+ name = PersonName(name)
+ name = name.full
+ name and names.append(name)
+
+ return names
+
+
+def normalize_date(value: str) -> datetime:
+ """Нормализует дату (в форматах, используемых в PEP), приводя к datetime."""
+ created = value.strip()
+
+ if created:
+
+ if '(' in created:
+ created = created[:created.index('(')].strip()
+
+ strptime = partial(datetime.strptime, created)
+
+ # 29-May-2011 / 18-June-2001 / 2011-03-16 / 30 Aug 2012 / 30 March 2013
+ for fmt in ['%d-%b-%Y', '%d-%B-%Y', '%Y-%m-%d', '%d %b %Y', '%d %B %Y']:
+
+ try:
+ created = timezone.make_aware(strptime(fmt), timezone.get_current_timezone())
+ break
+
+ except ValueError:
+ continue
+ else:
+ raise Exception(f'Unknown date format `{created}` in `{value}`')
+
+ return created
+
+
+def get_peps(exclude_peps: list[str] = None, limit: int = None) -> list[PepInfo]:
+ """Проходит по репозиторию PEPов и возвращает данные о них в виде
+ списка PEPInfo.
+
+ :param exclude_peps: Номера PEP (с ведущими нулями), которые можно пропустить.
+ :param limit: Максимальное количество PEP, которые следует обработать.
+
+ """
+ LOG.debug('Getting PEPs ...')
+
+ def make_list(pep: dict, key: str):
+ pep[key] = [int(chunk.strip()) for chunk in pep.get(key, '').split(',') if chunk.strip()] or []
+
+ def normalize_pep_info(pep: dict):
+
+ pep['pep'] = int(pep['pep'])
+ pep['author'] = strip_mail(pep['author'])
+
+ version = pep.get('python-version', '')
+
+ if version:
+ version = [match[0].replace(',', '.').replace('3000', '3.0') for match in RE_VERSION.findall(version)]
+
+ pep['python-version'] = version
+ pep['created'] = normalize_date(pep['created'])
+
+ make_list(pep, 'superseded-by')
+ make_list(pep, 'replaces')
+ make_list(pep, 'requires')
+
+ def get_pep_info(download_url: str) -> PepInfo:
+ LOG.debug('Getting PEP info from %s ...', download_url)
+
+ response = requests.get(download_url).text
+
+ info_dict = {}
+ header = []
+
+ for line in response.splitlines():
+
+ if not line.strip():
+ break
+
+ if line[0].strip():
+ if ':' not in line:
+ break
+
+ key, value = line.split(':', 1)
+ value = value.strip()
+ header.append((key, value))
+
+ else:
+ # продолжение секции
+ key, value = header[-1]
+ value = value + line
+ header[-1] = key, value
+
+ for key, value in header:
+ key = key.lower()
+
+ if key in KEYS_ALL:
+ info_dict[key] = value.strip()
+
+ normalize_pep_info(info_dict)
+
+ pep_info = PepInfo(
+ num=info_dict['pep'],
+ title=info_dict['title'],
+ versions=info_dict['python-version'],
+ status=info_dict['status'],
+ type=info_dict['type'],
+ created=info_dict['created'],
+ authors=info_dict['author'],
+ superseded=info_dict['superseded-by'],
+ replaces=info_dict['replaces'],
+ requires=info_dict['requires'],
+ )
+
+ return pep_info
+
+ exclude_peps = exclude_peps or []
+ peps = []
+ url_base = 'https://api.github.com/repos/python/peps/contents/peps?ref=main'
+
+ pep_counter = 0
+ limit = limit or float('inf')
+
+ json = requests.get(url_base).json()
+
+ for item in json:
+ name = item['name']
+
+ if not name.startswith('pep-'):
+ continue
+
+ name_split = splitext(name) # noqa: PTH122
+
+ ext = name_split[1]
+
+ if item['type'] == 'file' and ext in ('.txt', '.rst'):
+
+ pep_num = name_split[0].replace('pep-', '')
+
+ if pep_num in exclude_peps:
+ continue
+
+ peps.append(get_pep_info(item['download_url']))
+
+ pep_counter += 1
+
+ if pep_counter == limit:
+ break
+
+ LOG.debug('Getting PEPs done')
+
+ return peps
+
+
+def sync(*, skip_finalized: bool = True, limit: int = None) -> dict[int, 'PEP']:
+ """Синхронизирует данные БД сайта с данными PEP из репозитория.
+
+ :param skip_finalized: Следует ли пропустить ПУПы, состояние которых уже не измениться.
+ :param limit: Максимальное количество PEP, которые следует обработать.
+
+ """
+ from ..models import PEP, Person, PersonsLinked, Version # noqa: PLC0415
+
+ LOG.debug('Syncing PEPs ...')
+
+ map_statuses = {
+ 'Draft': PEP.Status.DRAFT,
+ 'Active': PEP.Status.ACTIVE,
+ 'Withdrawn': PEP.Status.WITHDRAWN,
+ 'Deferred': PEP.Status.DEFERRED,
+ 'Rejected': PEP.Status.REJECTED,
+ 'Accepted': PEP.Status.ACCEPTED,
+ 'Final': PEP.Status.FINAL,
+ 'Superseded': PEP.Status.SUPERSEDED,
+ 'April Fool!': PEP.Status.FOOL,
+
+ }
+ map_types = {
+ 'Process': PEP.Type.PROCESS,
+ 'Standards Track': PEP.Type.STANDARD,
+ 'Informational': PEP.Type.INFO,
+ }
+
+ exclude_peps = None
+
+ if skip_finalized:
+ exclude_peps = PEP.objects.filter(status__in=PEP.STATUSES_FINAL).values_list('slug', flat=True)
+
+ peps = get_peps(exclude_peps=exclude_peps, limit=limit)
+
+ known_peps: dict[int, PEP] = {pep.num: pep for pep in PEP.objects.all()}
+ known_versions = []
+
+ submitter_id = settings.ROBOT_USER_ID
+
+ for pep in peps:
+
+ num = pep.num
+ status_id = int(map_statuses.get(pep.status, 0))
+
+ if not status_id:
+ # Неизвестный статус. Например, Provisional.
+ LOG.warning('Unknown status %s ...', pep.status)
+ continue
+
+ type_id = int(map_types[pep.type])
+
+ LOG.info('Working on PEP %s ...', num)
+
+ if num in known_peps:
+
+ pep_model = known_peps[num]
+
+ if pep_model.status != status_id:
+ pep_model.status = status_id
+ pep_model.save(notify_published=False)
+
+ status_title = PEP.Status(status_id).label
+
+ msg = (
+ f'PEP {pep_model.num} сменил статус на «{status_title}»\n'
+ f'{pep_model.get_absolute_url(with_prefix=True)}'
+ )
+ sig_send_generic_telegram.send(None, text=msg)
+
+ else:
+ LOG.debug('PEP %s is new. Creating ...', num)
+
+ pep_model = PEP(
+ num=num,
+ title=pep.title,
+ description=pep.title,
+ status=status_id,
+ type=type_id,
+ time_published=pep.created or '2000-01-01',
+ submitter_id=submitter_id,
+ )
+ pep_model.save(notify_published=True)
+ known_peps[num] = pep_model
+
+ if pep.versions:
+ known_versions = known_versions or {v.title: v for v in Version.objects.all()}
+
+ # Регистрируем неизвестные сайту версии Питона, указанные в PEP.
+ for version in set(pep.versions).difference(known_versions):
+ known_versions[version] = Version.create_stub(version)
+
+ sync_many_to_many(pep, pep_model, 'versions', 'title', known_versions)
+
+ known_persons = Person.get_known_persons()
+
+ create_person = PersonsLinked.create_person
+
+ for pep in peps:
+ # Для правильного связывания необходимо, чтобы в БД уже были все известные PEP.
+ # В этом повторном проходе мы производим связывание.
+ pep_model = known_peps[pep.num]
+ sync_many_to_many(pep, pep_model, 'superseded', 'num', known_peps)
+ sync_many_to_many(pep, pep_model, 'replaces', 'num', known_peps)
+ sync_many_to_many(pep, pep_model, 'requires', 'num', known_peps)
+ sync_many_to_many(pep, pep_model, 'authors', 'name_en', known_persons, unknown_handler=create_person)
+
+ LOG.debug('Syncing PEPs done')
+
+ return known_peps
diff --git a/pythonz/apps/integration/pypistats.py b/pythonz/apps/integration/pypistats.py
new file mode 100644
index 00000000..d51f7900
--- /dev/null
+++ b/pythonz/apps/integration/pypistats.py
@@ -0,0 +1,26 @@
+from collections import defaultdict
+
+from .utils import get_json
+
+
+def get_for_package(name: str) -> dict[str, int]:
+ """Возвращает количество загрузок указанного пакета по месяцам
+ по данным pypistats.org.
+
+ https://pypistats.org/api/
+
+ :param name:
+
+ """
+ data = get_json(f'https://pypistats.org/api/packages/{name}/overall?mirrors=true')
+
+ if not data:
+ return {}
+
+ monthly = defaultdict(int)
+
+ for item in data.get('data', []):
+ month = item['date'].rsplit('-', 1)[0]
+ monthly[month] += item['downloads']
+
+ return monthly
diff --git a/pythonz/apps/integration/resources.py b/pythonz/apps/integration/resources.py
new file mode 100644
index 00000000..f19a872a
--- /dev/null
+++ b/pythonz/apps/integration/resources.py
@@ -0,0 +1,79 @@
+
+import feedparser
+
+from ..signals import sig_integration_failed
+from ..utils import get_logger
+
+if False: # pragma: nocover
+ from ..generics.realms import RealmBase # noqa
+
+LOG = get_logger(__name__)
+
+
+class PyDigestResource:
+ """Инструменты для получения данных со внешнего ресурса pythondigest.ru."""
+
+ mapping: dict[type['RealmBase'], tuple[str, ...]] = None
+
+ @classmethod
+ def get_mapping(cls) -> dict[type['RealmBase'], tuple[str, ...]]:
+ """Возвращает словарь соотношений классов областей псевдонимам разделов."""
+
+ if cls.mapping is None:
+ from ..realms import ArticleRealm, VideoRealm # noqa: PLC0415
+
+ mapping = {
+ ArticleRealm: ('article',),
+ VideoRealm: ('video',),
+ }
+ cls.mapping = mapping
+
+ return cls.mapping
+
+ @classmethod
+ def fetch_entries(cls) -> list[dict]:
+ """Собирает данные (записи) со внешнего ресурса, соотнося их с разделами pythonz."""
+
+ base_url = 'http://pythondigest.ru/rss/'
+
+ mapping = cls.get_mapping()
+ if not mapping:
+ return []
+
+ results = []
+ known_links = []
+
+ for realm, aliases in mapping.items():
+ realm_name, _ = realm.get_names()
+
+ entries_max = 5
+ if len(aliases) > 1:
+ entries_max = 3
+
+ for alias in aliases:
+ url = f'{base_url}{alias}/'
+
+ try:
+ parsed = feedparser.parse(url)
+
+ except Exception as e:
+ LOG.exception('Fetch digest error')
+ sig_integration_failed.send(None, description=f'URL {url}. Error: {e}')
+
+ else:
+
+ # reverse - по степени свежести (более свежие в конце).
+ for entry in reversed(parsed.entries[:entries_max]):
+ link = entry.link
+ if link in known_links:
+ continue
+
+ known_links.append(link)
+ results.append({
+ 'realm_name': realm_name,
+ 'url': link,
+ 'title': entry.title,
+ 'description': entry.summary,
+ })
+
+ return results
diff --git a/pythonz/apps/integration/summary/__init__.py b/pythonz/apps/integration/summary/__init__.py
new file mode 100644
index 00000000..5406eaf5
--- /dev/null
+++ b/pythonz/apps/integration/summary/__init__.py
@@ -0,0 +1,29 @@
+from .base import ItemsFetcherBase
+from .fetchers import (
+ Discuss,
+ GithubTrending,
+ Lwn,
+ MailarchAnnounce,
+ MailarchConferences,
+ MailarchIdeas,
+ Psf,
+)
+from .fetchers import (
+ Stackoverflow as Stackoverflow,
+)
+from .fetchers import (
+ StackoverflowRu as StackoverflowRu,
+)
+
+SUMMARY_FETCHERS: dict[str, type[ItemsFetcherBase]] = {fetcher.alias: fetcher for fetcher in [
+ MailarchAnnounce,
+ MailarchConferences,
+ MailarchIdeas,
+ Discuss,
+ Psf,
+ # Stackoverflow, # 403 ошибка при получении csv
+ # StackoverflowRu,
+ GithubTrending,
+ Lwn,
+]}
+"""Сборщики сводок, индексированные псевдонимами."""
diff --git a/pythonz/apps/integration/summary/base.py b/pythonz/apps/integration/summary/base.py
new file mode 100644
index 00000000..a63e1282
--- /dev/null
+++ b/pythonz/apps/integration/summary/base.py
@@ -0,0 +1,313 @@
+import csv
+import json
+import re
+from dataclasses import asdict, dataclass
+from datetime import datetime, timedelta
+from io import StringIO
+from traceback import format_exc
+
+from django.utils import timezone
+
+from ...exceptions import LogicError
+from ...signals import sig_integration_failed
+from ...utils import get_datetime_from_till, get_logger
+from ..utils import get_from_url, make_soup
+
+LOG = get_logger(__name__)
+
+
+@dataclass(frozen=True)
+class SummaryItem:
+ """Элемент сводки."""
+
+ url: str
+ title: str
+ description: str = ''
+
+ def to_json(self) -> str:
+ """Представляет объект в виде json."""
+ return json.dumps(asdict(self))
+
+
+TypeFetcherResult = tuple[list[SummaryItem], list | dict] | None
+
+
+class ItemsFetcherBase:
+ """Базовый класс для сборщиков данных из внешних ресурсов."""
+
+ title: str = None
+ """Название сводки."""
+
+ alias: str = None
+ """Псевдоним сводки. Однажды установленный не должен оставаться неизменным."""
+
+ filter_cumulative: bool = False
+ """Режим для работы с ресурсами, данные которых кумулятивны
+ (дополняются новые к имеющимся старым). В этом режиме все старые
+ данные будут отсеяны и останется единственный результат.
+
+ """
+
+ filter_skip_unchanged: bool = False
+ """Режим, при котором элементы старого забора исключаются из нового, если
+ присутствуют в нём.
+
+ """
+
+ filter_hide_seen = False
+ """Убирать ли из представления элементы присутствующие и в предыдущем, и в текущем заборах."""
+
+ def __init__(self, *, previous_result: list | dict, previous_dt: datetime | None, **kwargs):
+ """
+
+ :param previous_result: Результат предыдущего забора данных.
+ :param previous_dt: Дата и время предыдущего забора данных.
+ :param kwargs:
+
+ """
+ assert not all((self.filter_cumulative, self.filter_skip_unchanged)), (
+ 'Указаны взимоисключающие режимы фильтрации данных')
+
+ self.previous_result = previous_result or []
+ self.previous_dt = previous_dt or get_datetime_from_till(7)[0]
+
+ def run(self) -> TypeFetcherResult | None:
+ """Основной рабочий метод. Запускает сбор данных."""
+ fetcher_name = self.__class__.__name__
+
+ LOG.debug('Summary fetcher `%s` started ...', fetcher_name)
+
+ try:
+ fetched = self.fetch()
+
+ LOG.debug('... finished')
+
+ return fetched
+
+ except Exception as e:
+
+ sig_integration_failed.send(
+ None,
+ description=f'Summary fetcher `{fetcher_name}` error: {e} {format_exc()}')
+
+ LOG.exception('Summary fetcher `%s` failed', fetcher_name)
+
+ return None
+
+ def fetch(self) -> TypeFetcherResult:
+ """Забирает данные для последующей сборки в сводку.
+
+ Должен реализовываться наследниками.
+
+ Возвращает кортеж вида:
+ (список_SummaryItem, результ)
+
+ Результат используется для восставноления состояния забора
+ и будет передан в previous_result при следующем заборе данных.
+
+ """
+ raise NotImplementedError(f'`{self.__class__.__name__}` must implement .fetch()') # pragma: nocover
+
+ def _filter(self, items: dict) -> tuple[list[SummaryItem], list | dict]:
+ """
+ :param items:
+
+ """
+ result_old = self.previous_result
+
+ flt_cumulative = self.filter_cumulative
+ flt_skip_unchanged = self.filter_skip_unchanged
+ flt_hide_seen = self.filter_hide_seen
+
+ idx_prev = -1
+
+ if result_old:
+
+ if flt_cumulative:
+
+ if len(result_old) > 1:
+ raise LogicError(
+ f'`{self.__class__}`fetcher uses `mode_cumulative` '
+ 'but `previous_result` contains more than one item.')
+
+ result_old = result_old[0]
+
+ try:
+ idx_prev = list(items.keys()).index(result_old)
+
+ except ValueError:
+ pass
+
+ result_new = []
+ by_title = {}
+
+ for idx_current, (key, summary_item) in enumerate(items.items()):
+
+ summary_item: SummaryItem
+
+ seen = key in result_old
+
+ if flt_cumulative and idx_current <= idx_prev:
+ continue
+
+ elif flt_skip_unchanged and seen:
+ continue
+
+ if not seen or not flt_hide_seen:
+ by_title.setdefault(summary_item.title, summary_item)
+
+ result_new.append(key)
+
+ if flt_cumulative:
+ # Необходима и достаточна только одна запись
+ result_new = result_new[-1:]
+
+ return list(by_title.values()), result_new
+
+
+class HyperKittyBase(ItemsFetcherBase):
+ """Базовый сборщик данных из архивов почтовых рассылок
+ HyperKitty (Mailman 3) с mail.python.org.
+
+ """
+ url_base: str = 'https://mail.python.org'
+
+ def __init__(
+ self,
+ *,
+ previous_result: list,
+ previous_dt: datetime | None,
+ till: datetime | None = None,
+ **kwargs
+ ):
+ self.till = till
+ super().__init__(previous_result=previous_result, previous_dt=previous_dt, **kwargs)
+
+ def get_url(self, *, date: datetime) -> str:
+ return f"{self.url_base}/archives/list/{self.alias}/{date.strftime('%Y/%m/%d/')}"
+
+ def fetch(self) -> TypeFetcherResult:
+
+ since = self.previous_dt
+ till = self.till or timezone.now()
+
+ if not since:
+ since = till
+
+ delta = till.date() - since.date()
+ target_dates = [till - timedelta(days=day_num) for day_num in range(delta.days)] or [till]
+
+ items = {}
+ url_base = self.url_base
+
+ for target_date in target_dates:
+ url = self.get_url(date=target_date)
+ response = get_from_url(url)
+ soup = make_soup(response.text)
+
+ for link in soup.select('.thread-title a'):
+ item_url = f"{url_base}{link.attrs['href']}"
+ item_title = link.text.strip()
+ items[item_url] = SummaryItem(url=item_url, title=item_title)
+
+ items, latest_result = self._filter(items)
+
+ return items, latest_result
+
+
+class PipermailBase(ItemsFetcherBase):
+ """Базовый сборщик данных из архивов почтовых рассылок pipermail с mail.python.org"""
+
+ filter_cumulative: bool = True
+
+ def get_url(self) -> str:
+ return f'https://mail.python.org/pipermail/{self.alias}/'
+
+ def __init__(self, *, previous_result: list, previous_dt: datetime | None, year_month: str = None, **kwargs):
+ self.year_month = year_month
+ super().__init__(previous_result=previous_result, previous_dt=previous_dt, **kwargs)
+
+ def fetch(self) -> TypeFetcherResult:
+
+ url = self.get_url()
+ year_month = self.year_month
+
+ details_page_file = '/date.html'
+
+ if not year_month:
+ page = get_from_url(url)
+
+ match = re.search(rf'="((\d{4}-[^/]+){details_page_file})"', page.text)
+
+ if not match:
+ sig_integration_failed.send(None, description=f'Subject page link not found at {url}')
+ return
+
+ year_month = match.group(2)
+
+ page = get_from_url(f'{url}{year_month}{details_page_file}')
+ soup = make_soup(page.text)
+
+ items = {}
+
+ prefix_re = re.compile(r'\[[^]]+\]\s*')
+
+ list_items = soup.select('ul')[1].select('li')
+
+ for list_item in list_items:
+ link = list_item.select('a')[0]
+ item_url = url + year_month + '/' + link.attrs['href']
+ item_title = link.text.strip()
+
+ # Отсекаем префикст рассылки вида `[Префикс] Тема`
+ item_title = prefix_re.sub('', item_title)
+
+ items[item_url] = SummaryItem(url=item_url, title=item_title)
+
+ items, latest_result = self._filter(items)
+
+ return items, latest_result
+
+
+class StackdataBase(ItemsFetcherBase):
+ """Базовый сборщик данных из data.stackexchange.com.
+ Данные обновляются каждое воскресенье.
+
+ """
+
+ site: str = None
+ domain: str = None
+ query_revision_id: int = None
+
+ def __init__(self, *, previous_result: list, previous_dt: datetime | None, top_count: int = 10, **kwargs):
+ self.top_count = top_count
+ super().__init__(previous_result=previous_result, previous_dt=previous_dt, **kwargs)
+
+ def get_url(self) -> str:
+ since = self.previous_dt.strftime('%Y%m%d')
+ url = (
+ f'http://data.stackexchange.com/{self.site}/csv/{self.query_revision_id}?'
+ f'top={self.top_count}&since={since}'
+ )
+ return url
+
+ def get_item_url(self, item_id: int) -> str:
+ return f'https://{self.domain}/questions/{item_id}/'
+
+ def fetch(self) -> TypeFetcherResult:
+ url = self.get_url()
+ response = get_from_url(url)
+
+ items = {}
+
+ reader = csv.DictReader(StringIO(response.text))
+
+ for row in reader:
+ item_id = row['id']
+ item_url = self.get_item_url(item_id)
+ item_title = row['Title']
+ items[item_id] = SummaryItem(item_url, item_title)
+
+ items, latest_result = self._filter(items)
+
+ return items, latest_result
diff --git a/pythonz/apps/integration/summary/fetchers.py b/pythonz/apps/integration/summary/fetchers.py
new file mode 100644
index 00000000..45049a0c
--- /dev/null
+++ b/pythonz/apps/integration/summary/fetchers.py
@@ -0,0 +1,234 @@
+from collections import defaultdict
+from datetime import datetime
+
+from lxml import etree as ET
+
+from ..utils import get_from_url, get_json, make_soup
+from .base import HyperKittyBase, ItemsFetcherBase, PipermailBase, StackdataBase, SummaryItem, TypeFetcherResult
+
+
+class MailarchAnnounce(HyperKittyBase):
+
+ title: str = 'Анонсы'
+ alias: str = 'python-announce-list@python.org'
+
+
+class MailarchConferences(PipermailBase):
+
+ title: str = 'Мероприятия'
+ alias: str = 'conferences'
+
+
+class MailarchIdeas(HyperKittyBase):
+
+ title: str = 'Идеи'
+ alias: str = 'python-ideas@python.org'
+
+
+class Stackoverflow(StackdataBase):
+
+ title: str = 'StackOverflow'
+ alias: str = 'stack-en'
+ site: str = 'stackoverflow'
+ domain: str = 'stackoverflow.com'
+ query_revision_id: int = 851710
+ """https://data.stackexchange.com/stackoverflow/query/682499/top-n-python-questions-since-x"""
+
+
+class StackoverflowRu(StackdataBase):
+
+ title: str = 'StackOverflow на русском'
+ alias: str = 'stack-ru'
+ site: str = 'ru'
+ domain: str = 'ru.stackoverflow.com'
+ query_revision_id: int = 851710
+
+
+class Discuss(ItemsFetcherBase):
+ """Получатель обсуждений от discuss.python.org."""
+
+ title: str = 'Обсуждения'
+ alias: str = 'discuss'
+
+ url_base: str = 'https://discuss.python.org'
+
+ def fetch(self) -> TypeFetcherResult:
+
+ url_digest = f'{self.url_base}/top/weekly.json' # За неделю.
+ url_topic_prefix = f'{self.url_base}/t/'
+
+ items = {}
+ result = get_json(url_digest)
+
+ if not result:
+ return
+
+ for topic in result['topic_list']['topics']:
+ item_title = topic['title']
+ item_url = f"{url_topic_prefix}{topic['id']}"
+ items[item_url] = SummaryItem(url=item_url, title=item_title)
+
+ items, latest_result = self._filter(items)
+
+ return items, latest_result
+
+
+class Psf(ItemsFetcherBase):
+
+ title: str = 'Блог PSF'
+ alias: str = 'psf'
+
+ url_base: str = 'https://discuss.python.org'
+
+ filter_hide_seen = True
+
+ def fetch(self) -> TypeFetcherResult:
+
+ url_rss = 'https://pyfound.blogspot.com/feeds/posts/default'
+
+ items = {}
+ result = get_from_url(url_rss)
+ xml = ET.fromstring(result.content)
+
+ for entry in xml.findall('{http://www.w3.org/2005/Atom}entry'):
+ item_title = entry.find('{http://www.w3.org/2005/Atom}title').text.strip()
+ item_url = ''
+
+ for entry_url in entry.findall('{http://www.w3.org/2005/Atom}link'):
+ if entry_url.attrib['type'] == 'text/html':
+ item_url = entry_url.attrib['href']
+
+ if item_url:
+ items[item_url] = SummaryItem(url=item_url, title=item_title)
+
+ items, latest_result = self._filter(items)
+
+ return items, latest_result
+
+
+class Lwn(ItemsFetcherBase):
+
+ title: str = 'Материалы от LWN'
+ alias: str = 'lwn'
+
+ url_base: str = 'https://lwn.net'
+
+ relevant_cats: dict = {
+ 'EuroPython',
+ 'PyCon',
+ 'Python Language Summit',
+ }
+
+ def _filter(self, items: dict) -> tuple[list[SummaryItem], list | dict]:
+
+ previous_result = self.previous_result or {}
+ latest_result = {}
+ result = []
+
+ for category, category_items in items.items():
+ prev_latest_url = previous_result.get(category)
+
+ if prev_latest_url:
+ # Возможно найдётся новый материал в старой категории.
+ # Отсекаем ранее обработанные записи.
+ start_from = [item.url for item in category_items].index(prev_latest_url) + 1
+ result.extend(category_items[start_from:])
+
+ else:
+ # Новая категория.
+ result.extend(category_items)
+
+ latest_result[category] = category_items[-1].url
+
+ return result, latest_result
+
+ def fetch(self) -> TypeFetcherResult:
+
+ url_base = self.url_base
+
+ page = get_from_url(f'{url_base}/Archives/ConferenceIndex/')
+ soup = make_soup(page.text)
+
+ category = ''
+ relevant_cats = self.relevant_cats
+ by_category = defaultdict(list)
+
+ for paragraph in soup.select('p'):
+
+ css = paragraph.attrs.get('class')
+
+ if not css:
+ continue
+
+ css = css[0]
+
+ if css == 'IndexPrimary':
+ category = paragraph.select('a')[-1].text
+ continue
+
+ elif css == 'IndexEntry':
+ title, _, _ = paragraph.text.strip('\n )').rpartition('(')
+
+ is_relevant = category in relevant_cats
+ is_related = 'python' in title.lower()
+
+ if is_relevant or is_related:
+ url = paragraph.select('a')[0].attrs['href']
+ by_category[category].append(
+ SummaryItem(url=f'{url_base}{url}', title=f'{category}: {title}'))
+
+ return self._filter(by_category)
+
+
+class GithubTrending(ItemsFetcherBase):
+ """Сборщик данных о наиболее популярных репозиториях на GitHub."""
+
+ title: str = 'Популярное на GitHub'
+ alias: str = 'github_trending'
+
+ url_base: str = 'https://github.com'
+
+ filter_skip_unchanged: bool = True
+
+ def __init__(self, *, previous_result: list, previous_dt: datetime | None, period=None, **kwargs):
+ """
+
+ :param previous_result:
+ :param period: weekly, daily, monthly
+ :param kwargs:
+
+ """
+ self.period = period or 'weekly'
+ super().__init__(previous_result=previous_result, previous_dt=previous_dt, **kwargs)
+
+ def fetch(self) -> TypeFetcherResult:
+ period = self.period
+
+ url_base = self.url_base
+ url = f'{url_base}/trending/python?since={period}&spoken_language_code='
+
+ page = get_from_url(url)
+ soup = make_soup(page.text)
+
+ items = {}
+
+ list_items = soup.select('article')
+
+ for list_item in list_items:
+
+ link = list_item.select('h2 a')[0]
+
+ item_url = f"{url_base}{link.attrs['href']}"
+ item_title = link.text.strip().replace('\n', '').replace(' ', '')
+
+ try:
+ item_description = list_item.select('p')[0].text.strip()
+
+ except IndexError:
+ item_description = ''
+
+ items[item_url] = SummaryItem(item_url, item_title, item_description)
+
+ items, latest_result = self._filter(items)
+
+ return items, latest_result
diff --git a/pythonz/apps/integration/telegram.py b/pythonz/apps/integration/telegram.py
new file mode 100644
index 00000000..5746918b
--- /dev/null
+++ b/pythonz/apps/integration/telegram.py
@@ -0,0 +1,237 @@
+from functools import lru_cache
+from pathlib import Path
+from typing import TYPE_CHECKING, Union
+
+import telebot
+from bleach import clean
+from django.conf import settings
+from django.http import HttpRequest
+from telebot.types import InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, Message
+
+from ..models import PEP, Reference
+from ..utils import get_logger, truncate_chars
+from ..zen import ZEN
+
+if TYPE_CHECKING:
+ from ..generics.models import CommonEntityModel, RealmBaseModel
+
+LOGGER = get_logger('telebot')
+
+
+socks_proxy = settings.SOCKS5_PROXY
+
+if socks_proxy:
+ telebot.apihelper.proxy = {'https': f'socks5://{socks_proxy}'}
+
+
+bot = telebot.TeleBot(settings.TELEGRAM_BOT_TOKEN, threaded=False)
+
+
+def get_webhook_url() -> str:
+ """Возвращает webhook URL."""
+ return f"{settings.SITE_URL.replace('http', 'https')}/{settings.TELEGRAM_BOT_URL}/"
+
+
+def set_webhook() -> dict:
+ """Конфигурирует механизм webhook."""
+
+ certificate = None
+
+ if settings.PATH_CERTIFICATE and settings.CERTIFICATE_SELF_SIGNED:
+ certificate = Path.open(settings.PATH_CERTIFICATE)
+
+ bot.remove_webhook()
+
+ return bot.set_webhook(get_webhook_url(), certificate)
+
+
+def handle_request(request: HttpRequest):
+ """Обрабатывает обращения к URL для Webhook.
+
+ :param request:
+
+ """
+ if not request.body:
+ LOGGER.debug('No data supplied.')
+ return
+
+ update = telebot.types.Update.de_json(request.body.decode('utf8'))
+
+ message = update.message
+ inline_query = update.inline_query
+
+ if message:
+ LOGGER.debug('Got simple message.')
+ bot.process_new_messages([update.message])
+
+ elif inline_query:
+ LOGGER.debug('Got inline message.')
+ bot.process_new_inline_query([inline_query])
+
+
+@bot.message_handler(commands=['start'])
+def on_start(message: Message):
+ """Ответ на команду /start.
+
+ :param message:
+
+ """
+ LOGGER.debug('Got /start command.')
+ bot.reply_to(
+ message, f'Рад знакомству, {message.from_user.first_name}.\nЧтобы получить справку, наберите команду /help.')
+
+
+@bot.message_handler(commands=['chat_id'])
+def on_chat_id(message: Message):
+ """Ответ на команду /chat_id.
+
+ :param message:
+
+ """
+ LOGGER.debug('Got /chat_id command.')
+ bot.reply_to(message, f'Идентификатор этого чата: {message.chat.id}')
+
+
+@bot.message_handler(commands=['help'])
+def on_help(message: Message):
+ """Ответ на команду /help.
+
+ :param message:
+
+ """
+ LOGGER.debug('Got /help command.')
+ bot.reply_to(
+ message,
+ 'Я рассылаю новости сайта pythonz.net на канале https://telegram.me/pythonz.\n'
+ 'Кроме этого, вы можете вызвать меня в любом чате, чтобы получить ссылку на статью справочника.\n'
+ 'Пример: @pythonz_bot split')
+
+
+@lru_cache(maxsize=2)
+def get_inline_zen() -> list:
+ """Возвращает список цитат из дзена питона."""
+ results = []
+
+ for idx, (zen_en, zen_ru) in enumerate(ZEN, 1):
+ zen_en = clean(zen_en, tags=[], strip=True)
+ zen_ru = clean(zen_ru, tags=[], strip=True)
+ results.append(
+ telebot.types.InlineQueryResultArticle(
+ f'zen{idx}',
+ f'{idx}. {zen_ru}',
+ telebot.types.InputTextMessageContent(f'{idx}. {zen_en} — {zen_ru}'),
+ description=zen_en
+ ))
+
+ return results
+
+
+def compose_entities_inline_result(entities: list[Union['RealmBaseModel', 'CommonEntityModel']]) -> list:
+ """Возвращает список сущностей для вывода в качестве встрочных результатов поиска ботом.
+
+ :param entities:
+
+ """
+ results = []
+
+ for entity in entities:
+
+ title = str(entity)
+ # Усечение чтобы уложиться в 64 Кб на одно сообщение
+ # иначе, по словам техподдержки, получаем HTTP 414 Request-URI Too Large
+ description = truncate_chars(entity.description, 30)
+
+ results.append(
+ telebot.types.InlineQueryResultArticle(
+ str(entity.id),
+ title,
+ telebot.types.InputTextMessageContent(f'{title} — {entity.get_absolute_url(with_prefix=True)}'),
+ description=description
+ ))
+
+ return results
+
+
+@lru_cache(maxsize=64)
+def get_inline_reference(term: str, items_limit: int = 25) -> list:
+ """Возвращает статьи справочника.
+
+ :param term: Текст запроса
+ :param items_limit: Максимальное кол-во элементов для получения.
+
+ """
+ return compose_entities_inline_result(Reference.find(term[:200])[:items_limit])
+
+
+@lru_cache(maxsize=20)
+def get_inline_pep(term: str, items_limit: int = 10) -> list:
+ """Возвращает ссылки на PEP.
+
+ :param term: Текст запроса
+ :param items_limit: Максимальное кол-во элементов для получения.
+
+ """
+ return compose_entities_inline_result(PEP.find(term[:200])[:items_limit])
+
+
+@lru_cache(maxsize=2)
+def get_inline_no_query() -> list:
+ """Возвращает ответ на пустую строку запроса."""
+ markup = InlineKeyboardMarkup()
+ markup.row(InlineKeyboardButton('Бота в другой чат', switch_inline_query=''))
+ markup.row(
+ InlineKeyboardButton('Дзен', switch_inline_query_current_chat='zen '),
+ InlineKeyboardButton('Поиск PEP', switch_inline_query_current_chat='pep '),
+ )
+ markup.row(
+ InlineKeyboardButton('На pythonz.net', url='http://pythonz.net/'),
+ )
+
+ results = [
+ telebot.types.InlineQueryResultArticle(
+ 'index',
+ 'Пульт управления роботом',
+ telebot.types.InputTextMessageContent(
+ 'Нажимайте на кнопки, расположенные ниже, — получайте результат.'),
+ description='Нажмите сюда, чтобы вызвать пульт.',
+ reply_markup=markup,
+ )
+ ]
+ return results
+
+
+@bot.inline_handler(lambda query: True)
+def query_text(inline_query: InlineQuery):
+ """Ответ на запрос при вызове бота из чатов.
+
+ :param inline_query:
+
+ """
+ term = inline_query.query.strip()
+
+ if term:
+ if term.startswith('zen'):
+ results = get_inline_zen()
+
+ elif term.startswith('pep'):
+ results = get_inline_pep(term.replace('pep', '', 1).strip())
+
+ else:
+ results = get_inline_reference(term)
+
+ else:
+ results = get_inline_no_query()
+
+ LOGGER.debug('Answering inline.')
+ bot.answer_inline_query(inline_query.id, results)
+
+
+#@bot.message_handler(func=lambda message: True)
+def echo_message(message: Message):
+ """Ответ на неподдерживаемое сообщение.
+
+ :param message:
+
+ """
+ LOGGER.debug('Got unhandled message.')
+ bot.reply_to(message, f'{message.text}? Не знаю, что вам на это ответить.')
diff --git a/pythonz/apps/integration/utils.py b/pythonz/apps/integration/utils.py
new file mode 100644
index 00000000..dcced99e
--- /dev/null
+++ b/pythonz/apps/integration/utils.py
@@ -0,0 +1,322 @@
+from collections.abc import Callable
+from concurrent.futures import as_completed
+from concurrent.futures.thread import ThreadPoolExecutor
+from pathlib import Path
+from typing import TYPE_CHECKING, NamedTuple
+
+import requests
+from bs4 import BeautifulSoup
+from django.conf import settings
+from django.core.cache import cache
+from django.core.files.base import ContentFile
+from django.db.models.fields.files import ImageFieldFile
+from django.utils import timezone
+from PIL import Image, UnidentifiedImageError
+from requests import Response
+
+from ..signals import sig_integration_failed
+from ..utils import get_logger, truncate_chars, truncate_words
+
+if TYPE_CHECKING:
+ from ..generics.realms import RealmBase
+
+
+LOG = get_logger(__name__)
+
+class PageInfo(NamedTuple):
+ title: str
+ description: str
+ site_name: str
+ images: list[ImageFieldFile]
+
+
+def get_page_info(url: str, timeout: int = 4) -> PageInfo | None:
+ """Возвращает информацию о странице, расположенной
+ по указанному адресу, либо None.
+
+ :param url:
+ :param timeout: Таймаут на подключение.
+
+ """
+ return None
+
+
+def get_from_url(url: str, *, method: str = 'get', **kwargs) -> Response:
+ """Возвращает объект ответа requests с указанного URL.
+
+ По умолчанию запрос производится методом GET.
+
+ :param url:
+ :param method: get, post
+
+ """
+ r_kwargs = {
+ 'allow_redirects': True,
+ 'headers': {'User-agent': settings.USER_AGENT},
+ 'timeout': 15,
+ }
+ r_kwargs.update(kwargs)
+
+ method = getattr(requests, method)
+
+ return method(url, **r_kwargs)
+
+
+def get_json(url: str, *, return_none_statuses: set[int] = None, silent_statuses: set[int] = None) -> dict:
+ """Возвращает словарь, созданный из JSON документа, полученного
+ с указанного URL.
+
+ :param url:
+ :param return_none_statuses: Коды статусов, для которых требуется вернуть None.
+ :param silent_statuses: Коды статусов, для которых не требуется рассылать оповещения об ошибках.
+
+ """
+ return_none_statuses = return_none_statuses or set()
+ silent_statuses = silent_statuses or set()
+ result = {}
+
+ try:
+ response = get_from_url(url)
+ response.raise_for_status()
+
+ except requests.exceptions.RequestException as e:
+
+ status = getattr(e.response, 'status_code', 0)
+
+ if status in return_none_statuses:
+ return {}
+
+ elif status != 503 and status not in silent_statuses:
+ # Temporary Unavailable. В следующий раз получится.
+ sig_integration_failed.send(None, description=f'URL {url}.\nError: {e}')
+
+ else:
+
+ try:
+ result = response.json()
+
+ except ValueError:
+ pass
+
+ return result
+
+
+def get_image_from_url(url: str) -> ContentFile | None:
+ """Забирает изображение с указанного URL.
+
+ :param url:
+
+ """
+ response = requests.get(url)
+ content = response.content
+
+ if response.status_code != 200 or not content:
+ return None
+
+ return ContentFile(content, url.rstrip('/').rsplit('/', 1)[-1])
+
+
+def scrape_page(url: str) -> dict:
+ """Возвращает словарь с данными о странице, либо None в случае ошибок.
+
+ Словарь вида:
+ {'title': '...', 'content_more': '...', 'content_less': '...', ...}
+
+ :param url:
+
+ """
+ # Функция использовала ныне недоступный Rich Content API от Яндекса для получения данных о странице.
+ # Если функциональность будет востребована, нужно будет перевести на использование догого механизма.
+ result = {}
+
+ # todo Быть может перейти на get_page_info().
+
+ if 'content' not in result:
+ return {}
+
+ content = result['content']
+ result['content_less'] = truncate_words(content, 30)
+ result['content_more'] = truncate_chars(content, 900).replace('\n', '\n\n')
+
+ return result
+
+
+def make_soup(page: str) -> BeautifulSoup:
+ """Возвращает объект BeautifulSoup, либо None для указанного URL.
+
+ :param page:
+
+ """
+ return BeautifulSoup(page, 'lxml')
+
+
+def get_thumb_url(
+ realm: 'RealmBase',
+ image: ImageFieldFile,
+ width: int,
+ height: int,
+ *,
+ absolute_url: bool = False
+) -> str:
+ """Создаёт на лету уменьшенную копию указанного изображения.
+
+ :param realm:
+ :param image:
+ :param width:
+ :param height:
+ :param absolute_url:
+
+ """
+ base_path = Path('img') / realm.name_plural / 'thumbs' / f'{width}x{height}'
+
+ try:
+ thumb_file_base = base_path / Path(image.path).name
+
+ except (ValueError, AttributeError):
+ return ''
+
+ cache_key = f'thumbs|{thumb_file_base}|{absolute_url}'
+
+ url = cache.get(cache_key)
+
+ if url is None:
+
+ media_root = Path(settings.MEDIA_ROOT)
+ thumb_file = media_root / thumb_file_base
+
+ if not thumb_file.exists():
+
+ (media_root / base_path).mkdir(mode=0o755, parents=True, exist_ok=True)
+
+ try:
+ img = Image.open(image)
+
+ except UnidentifiedImageError:
+ # сбойное изображение
+ image.delete()
+ return ''
+
+ else:
+ img.thumbnail((width, height), Image.Resampling.LANCZOS)
+ img.convert('RGB').save(f'{thumb_file}', format=img.format.lower())
+
+ url = Path(settings.MEDIA_URL) / thumb_file_base
+ url = f'{url}'
+
+ if absolute_url:
+ url = f'{settings.SITE_URL}{url}'
+
+ cache.set(cache_key, url, 86400)
+
+ return url
+
+
+def get_timezone_name(lat: str, lng: str) -> str | None:
+ """Возвращает имя часового пояса по геокоординатам, либо None.
+ Использует Сервис Google Time Zone API.
+
+ :param lat: широта
+ :param lng: долгота
+
+ """
+ url = (
+ 'https://maps.googleapis.com/maps/api/timezone/json?'
+ f'location={lat},{lng}×tamp={timezone.now().timestamp()}&key={settings.GOOGLE_API_KEY}'
+ )
+
+ try:
+ result = requests.get(url)
+ doc = result.json()
+ tz_name = doc['timeZoneId']
+
+ except Exception:
+ LOG.exception('Timezone calc error')
+ return None
+
+ return tz_name
+
+
+def get_location_data(location_name: str) -> dict:
+ """Возвращает геоданные об объекте по его имени, либо None.
+ Использует API Яндекс.Карт.
+
+ :param location_name:
+
+ """
+ url = 'https://geocode-maps.yandex.ru/1.x/'
+
+ try:
+ result = requests.get(url, params={
+ 'results': 1,
+ 'format': 'json',
+ 'geocode': location_name,
+ 'apikey': settings.YANDEX_GEOCODER_KEY,
+ })
+ doc = result.json()
+
+ except Exception:
+ LOG.exception('Location find error')
+ return {}
+
+ response = doc.get('response')
+ if not response:
+ return {}
+
+ collection = response['GeoObjectCollection']
+ found = collection['metaDataProperty']['GeocoderResponseMetaData']['found']
+
+ if not int(found):
+ return {}
+
+ object_dict = collection['featureMember'][0]['GeoObject']
+ object_bounds_dict = object_dict['boundedBy']['Envelope']
+ object_metadata_dict = object_dict['metaDataProperty']['GeocoderMetaData']
+
+ location_data = {
+ 'requested_name': location_name,
+ 'type': object_metadata_dict['kind'],
+ 'name': object_metadata_dict['text'],
+ 'country': object_metadata_dict['AddressDetails']['Country']['CountryName'],
+ 'pos': ','.join(reversed(object_dict['Point']['pos'].split(' '))),
+ 'bounds': f"{object_bounds_dict['lowerCorner']}|{object_bounds_dict['upperCorner']}",
+ }
+
+ return location_data
+
+
+def run_threads(items: list | set, func: Callable, *, thread_num: int = None) -> dict[str, PageInfo | None]:
+ """Возвращает результаты обработки в нитях указанной функцией указанных элементов.
+
+ :param items: Элементы для обработки.
+
+ :param func: Функция для вызова.
+
+ :param thread_num: Количество нитей для забора данных.
+ Если не указано, то будет создано нитей по количеству элементов,
+ но не более определённого числа.
+
+ """
+ result = {}
+
+ if not thread_num:
+ max_auto_threads = 12
+
+ thread_num = len(items)
+
+ if thread_num > max_auto_threads:
+ thread_num = max_auto_threads
+
+ if not thread_num:
+ return result
+
+ with ThreadPoolExecutor(max_workers=thread_num) as executor:
+
+ task_to_item = {
+ executor.submit(func, item): item
+ for item in items}
+
+ for task in as_completed(task_to_item):
+ item = task_to_item[task]
+ result[item] = task.result()
+
+ return result
diff --git a/pythonz/apps/integration/vacancies.py b/pythonz/apps/integration/vacancies.py
new file mode 100644
index 00000000..5c64b402
--- /dev/null
+++ b/pythonz/apps/integration/vacancies.py
@@ -0,0 +1,86 @@
+
+from django.utils.dateparse import parse_datetime
+
+from .base import RemoteSource
+from .utils import get_json
+
+
+class VacancySource(RemoteSource):
+ """База для источников вакансий."""
+
+ realm: str = 'vacancy'
+
+ @classmethod
+ def get_status(cls, url: str) -> dict:
+ raise NotImplementedError # pragma: nocover
+
+
+class HhVacancy(VacancySource):
+ """Объединяет инструменты для работы с вакансиями с hh.ru."""
+
+ alias: str = 'hh'
+ title: str = 'hh.ru'
+
+ @classmethod
+ def get_status(cls, url: str) -> dict:
+ """Возвращает состояние вакансии по указанному URL.
+
+ :param url:
+
+ """
+ response = get_json(url, return_none_statuses={404}, silent_statuses={403})
+
+ if not response:
+ return response
+
+ return response['archived']
+
+ def fetch_list(self) -> list[dict]:
+ """Возвращает словарь с данными вакансий, полученный из внешнего
+ источника.
+
+ """
+ base_url = 'https://api.hh.ru/vacancies/'
+ query = 'search_field=name&per_page=100&order_by=publication_time&period=1&text=python'
+ response = get_json(f'{base_url}?{query}')
+
+ if 'items' not in response:
+ return []
+
+ results = []
+
+ for item in response['items']:
+
+ if item['archived']:
+ continue
+
+ salary_from = salary_till = salary_currency = ''
+
+ if item['salary']:
+ salary = item['salary']
+ salary_from = salary['from']
+ salary_till = salary['to']
+ salary_currency = salary['currency']
+
+ employer = item['employer']
+ url_logo = employer.get('logo_urls')
+
+ if url_logo:
+ url_logo = url_logo.get('90')
+
+ results.append({
+ 'src_id': item['id'],
+ 'src_place_name': item['area']['name'],
+ 'src_place_id': item['area']['id'],
+ 'title': item['name'],
+ 'url': item['alternate_url'],
+ 'url_api': item['url'],
+ 'url_logo': url_logo,
+ 'employer_name': employer['name'],
+ 'salary_from': salary_from or None,
+ 'salary_till': salary_till or None,
+ 'salary_currency': salary_currency,
+ 'time_published': parse_datetime(item['published_at']),
+ })
+
+ return results
diff --git a/pythonz/apps/integration/videos.py b/pythonz/apps/integration/videos.py
new file mode 100644
index 00000000..dbb7b475
--- /dev/null
+++ b/pythonz/apps/integration/videos.py
@@ -0,0 +1,100 @@
+
+from ..exceptions import RemoteSourceError
+
+
+class VideoBroker:
+
+ EMBED_WIDTH: int = 560
+ EMBED_HEIGHT: int = 315
+
+ hostings: dict[str, tuple[str, ...]] = {
+ 'Vimeo': ('vimeo.com', 'vimeo'),
+ 'YouTube': ('youtu', 'youtube'),
+ }
+ hostings = dict(sorted(hostings.items(), key=lambda k: k[0]))
+
+ @classmethod
+ def get_data_from_vimeo(cls, url: str) -> tuple[str, str]:
+
+ from ..integration.utils import get_json # noqa: PLC0415
+
+ if 'vimeo.com' not in url: # http://vimeo.com/{id}
+ raise RemoteSourceError(f'Не удалось обнаружить ID видео в URL `{url}`')
+
+ video_id = url.rsplit('/', 1)[-1]
+
+ embed_code = (
+ f'')
+
+ json = get_json(f'http://vimeo.com/api/v2/video/{video_id}.json')
+ cover_url = json[0]['thumbnail_small']
+
+ return embed_code, cover_url
+
+ @classmethod
+ def get_data_from_youtube(cls, url: str) -> tuple[str, str]:
+
+ if 'youtu.be' in url: # http://youtu.be/{id}
+ video_id = url.rsplit('/', 1)[-1]
+
+ elif 'watch?v=' in url: # http://www.youtube.com/watch?v={id}
+ video_id = url.rsplit('v=', 1)[-1]
+
+ else:
+ raise RemoteSourceError(f'Не удалось обнаружить ID видео в URL `{url}`')
+
+ cover_url = f"http://img.youtube.com/vi/{video_id.split('?', 1)[0]}/default.jpg"
+
+ if '?' in video_id:
+ video_id += '&'
+
+ else:
+ video_id += '?'
+
+ video_id = video_id.replace('t=', 'start=') + 'rel=0'
+
+ embed_code = (
+ f'')
+
+ return embed_code, cover_url
+
+ @classmethod
+ def get_hosting_for_url(cls, url: str) -> str:
+ hosting = ''
+
+ for data in cls.hostings.values():
+ search_str, hid = data
+
+ if search_str in url:
+ hosting = hid
+ break
+
+ return hosting
+
+ @classmethod
+ def get_code_and_cover(cls, url: str, *, wrap_responsive: bool = False) -> tuple[str, str]:
+
+ url = url.rstrip('/')
+ hid = cls.get_hosting_for_url(url)
+
+ msg = f'Не удалось обнаружить обработчик для указанного URL `{url}`'
+
+ if not hid:
+ raise RemoteSourceError(msg)
+
+ method_name = f'get_data_from_{hid}'
+ method = getattr(cls, method_name, None)
+
+ if method is None:
+ raise RemoteSourceError(msg)
+
+ embed_code, cover_url = method(url)
+
+ if wrap_responsive:
+ embed_code = f'{embed_code}
'
+
+ return embed_code, cover_url
diff --git a/apps/management/__init__.py b/pythonz/apps/management/__init__.py
similarity index 100%
rename from apps/management/__init__.py
rename to pythonz/apps/management/__init__.py
diff --git a/apps/management/commands/__init__.py b/pythonz/apps/management/commands/__init__.py
similarity index 100%
rename from apps/management/commands/__init__.py
rename to pythonz/apps/management/commands/__init__.py
diff --git a/pythonz/apps/management/commands/clean_missing_refs.py b/pythonz/apps/management/commands/clean_missing_refs.py
new file mode 100644
index 00000000..4949b5d2
--- /dev/null
+++ b/pythonz/apps/management/commands/clean_missing_refs.py
@@ -0,0 +1,19 @@
+from django.core.management.base import BaseCommand
+
+from ...commands import clean_missing_refs
+from ...utils import get_logger
+
+LOG = get_logger(__name__)
+
+
+class Command(BaseCommand):
+
+ help = 'Removes stale reference misses.'
+
+ def handle(self, *args, **options):
+
+ LOG.info('Cleaning reference misses ...')
+
+ clean_missing_refs()
+
+ LOG.info('Done')
diff --git a/pythonz/apps/management/commands/create_digest.py b/pythonz/apps/management/commands/create_digest.py
new file mode 100644
index 00000000..b3edf414
--- /dev/null
+++ b/pythonz/apps/management/commands/create_digest.py
@@ -0,0 +1,16 @@
+from django.core.management.base import BaseCommand
+
+from ...sitemessages import PythonzEmailDigest
+
+
+class Command(BaseCommand):
+
+ help = 'Creates dispatches for pythonz weekly digest.'
+
+ def handle(self, *args, **options):
+
+ self.stdout.write('Creating pythonz digest...\n')
+
+ PythonzEmailDigest.create()
+
+ self.stdout.write('Digest is created.\n')
diff --git a/pythonz/apps/management/commands/create_summary.py b/pythonz/apps/management/commands/create_summary.py
new file mode 100644
index 00000000..1c4df919
--- /dev/null
+++ b/pythonz/apps/management/commands/create_summary.py
@@ -0,0 +1,14 @@
+from django.core.management.base import BaseCommand
+
+from ...models import Summary
+
+
+class Command(BaseCommand):
+
+ help = 'Creates summary article using data from external sources.'
+
+ def handle(self, *args, **options):
+
+ self.stdout.write('Creating summary ...\n')
+ Summary.create_article()
+ self.stdout.write('Summary is created.\n')
diff --git a/apps/management/commands/generate_slugs.py b/pythonz/apps/management/commands/generate_slugs.py
similarity index 63%
rename from apps/management/commands/generate_slugs.py
rename to pythonz/apps/management/commands/generate_slugs.py
index df58d2f5..f79ab0dd 100644
--- a/apps/management/commands/generate_slugs.py
+++ b/pythonz/apps/management/commands/generate_slugs.py
@@ -8,21 +8,22 @@ class Command(BaseCommand):
help = 'Generates slugs for given realm model'
args = '[realm_name realm_name ...]'
- def handle(self, *realm_names, **options):
+ def handle(self, *realm_names: str, **options):
self.stdout.write('Starting slugs generation ...\n')
for realm_name in realm_names:
realm = get_realm(realm_name)
+
if realm is None:
- self.stderr.write(self.style.ERROR('Unknown realm: %s\n' % realm_name))
+ self.stderr.write(self.style.ERROR(f'Unknown realm: {realm_name}\n'))
else:
model_cls = realm.model
- if hasattr(model_cls, 'autogenerate_slug'):
+ if hasattr(model_cls, 'slug_auto'):
- self.stdout.write('Processing %s model ...\n' % model_cls.__name__)
+ self.stdout.write(f'Processing {model_cls.__name__} model ...\n')
realm_iface = hasattr(model_cls, 'mark_unmodified')
for obj in model_cls.objects.all():
@@ -30,10 +31,10 @@ def handle(self, *realm_names, **options):
if realm_iface:
obj.mark_unmodified()
- self.stdout.write('Processing %s ...\n' % obj)
+ self.stdout.write(f'Processing {obj} ...\n')
obj.save()
else:
- self.stderr.write(self.style.ERROR('%s has no slug support.\n'))
+ self.stderr.write(self.style.ERROR(f'{model_cls} has no slug support.\n'))
self.stdout.write('All is done.\n')
diff --git a/pythonz/apps/management/commands/merge_persons.py b/pythonz/apps/management/commands/merge_persons.py
new file mode 100644
index 00000000..9536424c
--- /dev/null
+++ b/pythonz/apps/management/commands/merge_persons.py
@@ -0,0 +1,32 @@
+from django.core.management.base import BaseCommand
+
+from ...models import PEP, Person
+from ...utils import get_logger
+
+LOG = get_logger(__name__)
+
+
+class Command(BaseCommand):
+
+ help = 'Merges the first persons into second. Deletes the first.'
+ args = '[stale_person_id target_person_id]'
+
+ def add_arguments(self, parser):
+ parser.add_argument('stale_person_id')
+ parser.add_argument('target_person_id')
+
+ def handle(self, stale_person_id: int, target_person_id: int, **options):
+
+ LOG.info('Merging person %s into %s ...', stale_person_id, target_person_id)
+
+ # Person exists sanity check
+ Person.objects.get(pk=target_person_id)
+ stale_person = Person.objects.get(pk=stale_person_id)
+
+ # PEP authorship
+ linker_model = PEP.authors.through
+ linker_model.objects.filter(person_id=stale_person_id).update(person_id=target_person_id)
+
+ stale_person.delete()
+
+ LOG.info('Merging persons done')
diff --git a/pythonz/apps/management/commands/publish_postponed.py b/pythonz/apps/management/commands/publish_postponed.py
new file mode 100644
index 00000000..224300fc
--- /dev/null
+++ b/pythonz/apps/management/commands/publish_postponed.py
@@ -0,0 +1,16 @@
+from django.core.management.base import BaseCommand
+
+from ...commands import publish_postponed
+
+
+class Command(BaseCommand):
+
+ help = 'Publishes posponed materials'
+
+ def handle(self, *realm_names, **options):
+
+ self.stdout.write('Postponed publishing started ...\n')
+
+ publish_postponed()
+
+ self.stdout.write('Postponed publishing finished.\n')
diff --git a/pythonz/apps/management/commands/recompile_texts.py b/pythonz/apps/management/commands/recompile_texts.py
new file mode 100644
index 00000000..590cb2c2
--- /dev/null
+++ b/pythonz/apps/management/commands/recompile_texts.py
@@ -0,0 +1,31 @@
+from django.core.management.base import BaseCommand
+
+from ...models import Article, Community, Discussion, Event, Person, Reference, Version
+
+
+class Command(BaseCommand):
+
+ help = 'Recompiles rst-like texts into html.'
+
+ def handle(self, *args, **options):
+
+ self.stdout.write('Recompiling texts ...\n')
+
+ models = [
+ Discussion,
+ Community,
+ Article,
+ Version,
+ Reference,
+ Event,
+ Person,
+ ]
+
+ for model in models:
+
+ for item in model.objects.all():
+ item.text_src = item.text_src.rstrip('-_')
+ item.mark_unmodified()
+ item.save()
+
+ self.stdout.write('Texts recompiled.\n')
diff --git a/pythonz/apps/management/commands/set_telegram_webhook.py b/pythonz/apps/management/commands/set_telegram_webhook.py
new file mode 100644
index 00000000..bab6b9d1
--- /dev/null
+++ b/pythonz/apps/management/commands/set_telegram_webhook.py
@@ -0,0 +1,20 @@
+from django.conf import settings
+from django.core.management.base import BaseCommand
+
+from ...integration.telegram import get_webhook_url, set_webhook
+
+
+class Command(BaseCommand):
+
+ help = 'Registers a webhook URL from Telegram Bot'
+
+ def handle(self, *args, **options):
+
+ self.stdout.write(f'Registering a webhook at {get_webhook_url()} ...')
+
+ self_signed = settings.PATH_CERTIFICATE and settings.CERTIFICATE_SELF_SIGNED
+ self.stdout.write(f"Using a self-signed certificate: {'TRUE' if self_signed else 'FALSE'}")
+
+ set_webhook()
+
+ self.stdout.write('All is done.\n')
diff --git a/pythonz/apps/management/commands/sync_pep_authors.py b/pythonz/apps/management/commands/sync_pep_authors.py
new file mode 100644
index 00000000..ea835d1f
--- /dev/null
+++ b/pythonz/apps/management/commands/sync_pep_authors.py
@@ -0,0 +1,19 @@
+from django.core.management.base import BaseCommand
+
+from ...integration.peps import sync
+from ...utils import get_logger
+
+LOG = get_logger(__name__)
+
+
+class Command(BaseCommand):
+
+ help = 'Updates persons from PEP authors'
+
+ def handle(self, *args, **options):
+
+ LOG.info('Updating persons ...')
+
+ sync(skip_finalized=False)
+
+ LOG.info('Updating persons done')
diff --git a/pythonz/apps/management/commands/sync_peps.py b/pythonz/apps/management/commands/sync_peps.py
new file mode 100644
index 00000000..3dca5410
--- /dev/null
+++ b/pythonz/apps/management/commands/sync_peps.py
@@ -0,0 +1,21 @@
+from django.conf import settings
+from django.core.management.base import BaseCommand
+
+from ...models import PEP
+
+# PEP.sync_from_repository() может вызывать систему оповещений,
+# для которой необходимо, чтобы работала reverse(). Подгружаем данные о доступных URL.
+__import__(settings.ROOT_URLCONF)
+
+
+class Command(BaseCommand):
+
+ help = 'Updates local PEPs data using remote repository'
+
+ def handle(self, *realm_names, **options):
+
+ self.stdout.write('Starting PEP update ...\n')
+
+ PEP.sync_from_repository()
+
+ self.stdout.write('PEP update is done.\n')
diff --git a/pythonz/apps/management/commands/sync_realms_persons.py b/pythonz/apps/management/commands/sync_realms_persons.py
new file mode 100644
index 00000000..8635d18f
--- /dev/null
+++ b/pythonz/apps/management/commands/sync_realms_persons.py
@@ -0,0 +1,26 @@
+
+from django.core.management.base import BaseCommand
+
+from ...models import Book, Person, Video
+from ...utils import get_logger
+
+LOG = get_logger(__name__)
+
+
+class Command(BaseCommand):
+
+ help = 'Links realms entities to person profiles.'
+
+ def handle(self, *args, **options):
+
+ LOG.info('Linking to persons ...')
+
+ known_persons = Person.get_known_persons()
+
+ for model_cls in (Video, Book):
+
+ for item in model_cls.objects.all():
+ item: Video | Book
+ item.sync_persons_fields(known_persons)
+
+ LOG.info('Linking to persons done')
diff --git a/pythonz/apps/management/commands/update_events.py b/pythonz/apps/management/commands/update_events.py
new file mode 100644
index 00000000..434e666e
--- /dev/null
+++ b/pythonz/apps/management/commands/update_events.py
@@ -0,0 +1,16 @@
+from django.core.management.base import BaseCommand
+
+from ...models import Event
+
+
+class Command(BaseCommand):
+
+ help = 'Updates events from remote sources.'
+
+ def handle(self, *args, **options):
+
+ self.stdout.write('Updating events...\n')
+
+ Event.fetch_items()
+
+ self.stdout.write('Events updated.\n')
diff --git a/pythonz/apps/management/commands/update_ext_resources.py b/pythonz/apps/management/commands/update_ext_resources.py
new file mode 100644
index 00000000..0a4e8c64
--- /dev/null
+++ b/pythonz/apps/management/commands/update_ext_resources.py
@@ -0,0 +1,16 @@
+from django.core.management.base import BaseCommand
+
+from ...models import ExternalResource
+
+
+class Command(BaseCommand):
+
+ help = 'Updates remote resources.'
+
+ def handle(self, *args, **options):
+
+ self.stdout.write('Updating resources ...\n')
+
+ ExternalResource.fetch_new()
+
+ self.stdout.write('Resources updated.\n')
diff --git a/pythonz/apps/management/commands/update_vacancies.py b/pythonz/apps/management/commands/update_vacancies.py
new file mode 100644
index 00000000..d196f0e0
--- /dev/null
+++ b/pythonz/apps/management/commands/update_vacancies.py
@@ -0,0 +1,17 @@
+from django.core.management.base import BaseCommand
+
+from ...models import Vacancy
+
+
+class Command(BaseCommand):
+
+ help = 'Updates vacancies from remote sources.'
+
+ def handle(self, *args, **options):
+
+ self.stdout.write('Updating vacancies...\n')
+
+ Vacancy.update_statuses()
+ Vacancy.fetch_items()
+
+ self.stdout.write('Vacancies updated.\n')
diff --git a/apps/metrics.py b/pythonz/apps/metrics.py
similarity index 84%
rename from apps/metrics.py
rename to pythonz/apps/metrics.py
index e0587fa2..9b72a48f 100644
--- a/apps/metrics.py
+++ b/pythonz/apps/metrics.py
@@ -1,3 +1,4 @@
+
from sitemetrics.providers import Yandex
@@ -6,9 +7,9 @@ class MyYandex(Yandex):
Подключаем некоторые плюшки из тех, что умеет счётчик.
"""
- params = {
- 'webvisor': False,
- 'clickmap': False,
+ params: dict[str, bool] = {
+ 'webvisor': True,
+ 'clickmap': True,
'track_links': True,
'accurate_bounce': True,
'no_index': False,
diff --git a/apps/middleware.py b/pythonz/apps/middleware.py
similarity index 64%
rename from apps/middleware.py
rename to pythonz/apps/middleware.py
index b4545ec7..7668b664 100644
--- a/apps/middleware.py
+++ b/pythonz/apps/middleware.py
@@ -1,21 +1,31 @@
-from pytz import UnknownTimeZoneError
-from django.utils import timezone
from django.contrib.auth.models import AnonymousUser
+from django.http import HttpRequest, HttpResponse
+from django.utils import timezone
+from pytz import UnknownTimeZoneError
-class TimezoneMiddleware(object):
+def TimezoneMiddleware(get_response):
"""Устанавливает текущую временную зону."""
- def process_request(self, request):
+ def middleware(request: HttpRequest) -> HttpResponse:
+
default_timezone = 'Asia/Novosibirsk'
current_timezone = default_timezone
user = getattr(request, 'user', None)
+
if user is not None and not isinstance(user, AnonymousUser):
- if user.timezone:
- current_timezone = user.timezone
+ if tz := user.timezone:
+ current_timezone = tz
try:
timezone.activate(current_timezone)
+
except UnknownTimeZoneError:
timezone.activate(default_timezone)
+
+ response = get_response(request)
+
+ return response
+
+ return middleware
diff --git a/apps/migrations/0001_initial.py b/pythonz/apps/migrations/0001_initial.py
similarity index 96%
rename from apps/migrations/0001_initial.py
rename to pythonz/apps/migrations/0001_initial.py
index f4cd77b0..f4f17629 100644
--- a/apps/migrations/0001_initial.py
+++ b/pythonz/apps/migrations/0001_initial.py
@@ -5,7 +5,7 @@
from django.conf import settings
import django.utils.timezone
import etc.models
-import apps.generics.models
+from ..generics.models import get_upload_to
import django.core.validators
@@ -56,7 +56,7 @@ class Migration(migrations.Migration):
('text_src', models.TextField(verbose_name='Исходный текст')),
('title', models.CharField(max_length=255, verbose_name='Название', unique=True)),
('description', models.TextField(verbose_name='Описание', help_text='Пара-тройка предложений, описывающих, о чём пойдёт речь в статье.')),
- ('cover', models.ImageField(max_length=255, verbose_name='Обложка', upload_to=apps.generics.models.get_upload_to, null=True, blank=True)),
+ ('cover', models.ImageField(max_length=255, verbose_name='Обложка', upload_to=get_upload_to, null=True, blank=True)),
('year', models.CharField(max_length=10, verbose_name='Год', null=True, blank=True)),
('time_created', models.DateTimeField(verbose_name='Дата создания', auto_now_add=True)),
('time_published', models.DateTimeField(verbose_name='Дата публикации', null=True, editable=False)),
@@ -64,7 +64,7 @@ class Migration(migrations.Migration):
('status', models.PositiveIntegerField(verbose_name='Статус', choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален')], default=1)),
('supporters_num', models.PositiveIntegerField(verbose_name='Количество поддержавших', default=0)),
('linked', models.ManyToManyField(verbose_name='Связанные объекты', to='apps.Article', related_name='linked_rel_+', help_text='Выберите объекты, имеющие отношение к данному.', blank=True)),
- ('submitter', models.ForeignKey(verbose_name='Добавил', to=settings.AUTH_USER_MODEL, related_name='article_submitters')),
+ ('submitter', models.ForeignKey(verbose_name='Добавил', to=settings.AUTH_USER_MODEL, related_name='article_submitters', on_delete=models.CASCADE)),
],
options={
'verbose_name': 'Статья',
@@ -80,7 +80,7 @@ class Migration(migrations.Migration):
('translator', models.CharField(max_length=255, verbose_name='Перевод', help_text='Укажите переводчиков, если материал переведён на русский с другого языка. Если переводчик неизвестен, можно указать главного редактора.[u:<ид>:<имя>] формирует ссылку на профиль пользователя pythonz. Например: [u:1:идле].', null=True, blank=True)),
('title', models.CharField(max_length=255, verbose_name='Название', unique=True)),
('description', models.TextField(verbose_name='Описание', help_text='Аннотация к книге, или другое краткое описание. Без обозначения личного отношения.')),
- ('cover', models.ImageField(max_length=255, verbose_name='Обложка', upload_to=apps.generics.models.get_upload_to, null=True, blank=True)),
+ ('cover', models.ImageField(max_length=255, verbose_name='Обложка', upload_to=get_upload_to, null=True, blank=True)),
('year', models.CharField(max_length=10, verbose_name='Год', null=True, blank=True)),
('time_created', models.DateTimeField(verbose_name='Дата создания', auto_now_add=True)),
('time_published', models.DateTimeField(verbose_name='Дата публикации', null=True, editable=False)),
@@ -90,7 +90,7 @@ class Migration(migrations.Migration):
('isbn', models.CharField(max_length=20, verbose_name='Код ISBN', unique=True, null=True, blank=True)),
('isbn_ebook', models.CharField(max_length=20, verbose_name='Код ISBN эл. книги', unique=True, null=True, blank=True)),
('linked', models.ManyToManyField(verbose_name='Связанные объекты', to='apps.Book', related_name='linked_rel_+', help_text='Выберите объекты, имеющие отношение к данному.', blank=True)),
- ('submitter', models.ForeignKey(verbose_name='Добавил', to=settings.AUTH_USER_MODEL, related_name='book_submitters')),
+ ('submitter', models.ForeignKey(verbose_name='Добавил', to=settings.AUTH_USER_MODEL, related_name='book_submitters', on_delete=models.CASCADE)),
],
options={
'verbose_name': 'Книга',
@@ -106,7 +106,7 @@ class Migration(migrations.Migration):
('text_src', models.TextField(verbose_name='Исходный текст')),
('title', models.CharField(max_length=255, verbose_name='Название', unique=True)),
('description', models.TextField(verbose_name='Описание')),
- ('cover', models.ImageField(max_length=255, verbose_name='Обложка', upload_to=apps.generics.models.get_upload_to, null=True, blank=True)),
+ ('cover', models.ImageField(max_length=255, verbose_name='Обложка', upload_to=get_upload_to, null=True, blank=True)),
('year', models.CharField(max_length=10, verbose_name='Год', null=True, blank=True)),
('time_created', models.DateTimeField(verbose_name='Дата создания', auto_now_add=True)),
('time_published', models.DateTimeField(verbose_name='Дата публикации', null=True, editable=False)),
@@ -146,8 +146,8 @@ class Migration(migrations.Migration):
('status', models.PositiveIntegerField(verbose_name='Статус', choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален')], default=1)),
('supporters_num', models.PositiveIntegerField(verbose_name='Количество поддержавших', default=0)),
('object_id', models.PositiveIntegerField(verbose_name='ID объекта', db_index=True)),
- ('content_type', models.ForeignKey(verbose_name='Тип содержимого', to='contenttypes.ContentType', related_name='opinion_opinions')),
- ('submitter', models.ForeignKey(verbose_name='Автор', to=settings.AUTH_USER_MODEL)),
+ ('content_type', models.ForeignKey(verbose_name='Тип содержимого', to='contenttypes.ContentType', related_name='opinion_opinions', on_delete=models.CASCADE)),
+ ('submitter', models.ForeignKey(verbose_name='Автор', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'verbose_name': 'Мнение',
@@ -184,7 +184,7 @@ class Migration(migrations.Migration):
('translator', models.CharField(max_length=255, verbose_name='Перевод', help_text='Укажите переводчиков, если материал переведён на русский с другого языка. Если переводчик неизвестен, можно указать главного редактора.[u:<ид>:<имя>] формирует ссылку на профиль пользователя pythonz. Например: [u:1:идле].', null=True, blank=True)),
('title', models.CharField(max_length=255, verbose_name='Название', unique=True)),
('description', models.TextField(verbose_name='Описание', help_text='Краткое описание того, о чём это видео. Без обозначения личного отношения.')),
- ('cover', models.ImageField(max_length=255, verbose_name='Обложка', upload_to=apps.generics.models.get_upload_to, null=True, blank=True)),
+ ('cover', models.ImageField(max_length=255, verbose_name='Обложка', upload_to=get_upload_to, null=True, blank=True)),
('year', models.CharField(max_length=10, verbose_name='Год', null=True, blank=True)),
('time_created', models.DateTimeField(verbose_name='Дата создания', auto_now_add=True)),
('time_published', models.DateTimeField(verbose_name='Дата публикации', null=True, editable=False)),
@@ -194,7 +194,7 @@ class Migration(migrations.Migration):
('code', models.TextField(verbose_name='Код')),
('url', models.URLField(verbose_name='URL')),
('linked', models.ManyToManyField(verbose_name='Связанные объекты', to='apps.Video', related_name='linked_rel_+', help_text='Выберите объекты, имеющие отношение к данному.', blank=True)),
- ('submitter', models.ForeignKey(verbose_name='Добавил', to=settings.AUTH_USER_MODEL, related_name='video_submitters')),
+ ('submitter', models.ForeignKey(verbose_name='Добавил', to=settings.AUTH_USER_MODEL, related_name='video_submitters', on_delete=models.CASCADE)),
],
options={
'verbose_name': 'Видео',
@@ -209,7 +209,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='eventdetails',
name='place',
- field=models.ForeignKey(verbose_name='Место', to='apps.Place', related_name='events'),
+ field=models.ForeignKey(verbose_name='Место', to='apps.Place', related_name='events', on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AddField(
@@ -227,13 +227,13 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='event',
name='submitter',
- field=models.ForeignKey(verbose_name='Добавил', to=settings.AUTH_USER_MODEL, related_name='event_submitters'),
+ field=models.ForeignKey(verbose_name='Добавил', to=settings.AUTH_USER_MODEL, related_name='event_submitters', on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AddField(
model_name='user',
name='place',
- field=models.ForeignKey(help_text='Место вашего пребывания (страна, город, село), чтобы pythonz мог фильтровать интересную вам информацию.', blank=True, verbose_name='Место', to='apps.Place', related_name='users', null=True),
+ field=models.ForeignKey(help_text='Место вашего пребывания (страна, город, село), чтобы pythonz мог фильтровать интересную вам информацию.', blank=True, verbose_name='Место', to='apps.Place', related_name='users', on_delete=models.CASCADE, null=True),
preserve_default=True,
),
migrations.AddField(
diff --git a/apps/migrations/0002_user_timezone.py b/pythonz/apps/migrations/0002_user_timezone.py
similarity index 100%
rename from apps/migrations/0002_user_timezone.py
rename to pythonz/apps/migrations/0002_user_timezone.py
diff --git a/apps/migrations/0003_auto_20140926_0529.py b/pythonz/apps/migrations/0003_auto_20140926_0529.py
similarity index 96%
rename from apps/migrations/0003_auto_20140926_0529.py
rename to pythonz/apps/migrations/0003_auto_20140926_0529.py
index fc340ce4..8ee4a635 100644
--- a/apps/migrations/0003_auto_20140926_0529.py
+++ b/pythonz/apps/migrations/0003_auto_20140926_0529.py
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='place',
- field=models.ForeignKey(blank=True, null=True, to='apps.Place', verbose_name='Место', help_text='Место вашего пребывания (страна, город, село). Например: Россия, Новосибирск.', related_name='users'),
+ field=models.ForeignKey(blank=True, null=True, to='apps.Place', verbose_name='Место', help_text='Место вашего пребывания (страна, город, село). Например: Россия, Новосибирск.', related_name='users', on_delete=models.CASCADE),
),
migrations.AlterField(
model_name='user',
diff --git a/apps/migrations/0004_auto_20140926_0808.py b/pythonz/apps/migrations/0004_auto_20140926_0808.py
similarity index 56%
rename from apps/migrations/0004_auto_20140926_0808.py
rename to pythonz/apps/migrations/0004_auto_20140926_0808.py
index bea467e9..c7503642 100644
--- a/apps/migrations/0004_auto_20140926_0808.py
+++ b/pythonz/apps/migrations/0004_auto_20140926_0808.py
@@ -19,6 +19,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='place',
- field=models.ForeignKey(related_name='users', blank=True, verbose_name='Место', to='apps.Place', help_text='Место вашего пребывания (страна, город, село). Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', null=True),
+ field=models.ForeignKey(related_name='users', on_delete=models.CASCADE, blank=True, verbose_name='Место', to='apps.Place', help_text='Место вашего пребывания (страна, город, село). Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', null=True),
),
]
diff --git a/apps/migrations/0005_place_description.py b/pythonz/apps/migrations/0005_place_description.py
similarity index 100%
rename from apps/migrations/0005_place_description.py
rename to pythonz/apps/migrations/0005_place_description.py
diff --git a/apps/migrations/0006_auto_20140926_1301.py b/pythonz/apps/migrations/0006_auto_20140926_1301.py
similarity index 100%
rename from apps/migrations/0006_auto_20140926_1301.py
rename to pythonz/apps/migrations/0006_auto_20140926_1301.py
diff --git a/apps/migrations/0007_auto_20141004_1034.py b/pythonz/apps/migrations/0007_auto_20141004_1034.py
similarity index 100%
rename from apps/migrations/0007_auto_20141004_1034.py
rename to pythonz/apps/migrations/0007_auto_20141004_1034.py
diff --git a/apps/migrations/0008_community.py b/pythonz/apps/migrations/0008_community.py
similarity index 82%
rename from apps/migrations/0008_community.py
rename to pythonz/apps/migrations/0008_community.py
index fade7636..30cbf01e 100644
--- a/apps/migrations/0008_community.py
+++ b/pythonz/apps/migrations/0008_community.py
@@ -2,7 +2,7 @@
from __future__ import unicode_literals
from django.db import models, migrations
-import apps.generics.models
+from ..generics.models import get_upload_to
from django.conf import settings
import etc.models
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
('text_src', models.TextField(verbose_name='Исходный текст')),
('title', models.CharField(verbose_name='Название', max_length=255, unique=True)),
('description', models.TextField(verbose_name='Описание', help_text='Пара-тройка предложений, описывающих, о чём пойдёт речь в статье.')),
- ('cover', models.ImageField(null=True, verbose_name='Обложка', upload_to=apps.generics.models.get_upload_to, max_length=255, blank=True)),
+ ('cover', models.ImageField(null=True, verbose_name='Обложка', upload_to=get_upload_to, max_length=255, blank=True)),
('year', models.CharField(null=True, verbose_name='Год', max_length=10, blank=True)),
('time_created', models.DateTimeField(verbose_name='Дата создания', auto_now_add=True)),
('time_published', models.DateTimeField(null=True, verbose_name='Дата публикации', editable=False)),
@@ -32,8 +32,8 @@ class Migration(migrations.Migration):
('url', models.URLField(null=True, verbose_name='Страница в сети', blank=True)),
('contacts', models.CharField(verbose_name='Контактное лицо', help_text='Контактные лица, представляющие сообщество, координаторы, основатели.[u:<ид>:<имя>] формирует ссылку на профиль пользователя pythonz. Например: [u:1:идле].', max_length=255)),
('linked', models.ManyToManyField(verbose_name='Связанные объекты', help_text='Выберите объекты, имеющие отношение к данному.', to='apps.Community', blank=True, related_name='linked_rel_+')),
- ('place', models.ForeignKey(to='apps.Place', verbose_name='Место', related_name='communities', null=True, help_text='Для географически локализованных сообществ можно указать место (страна, город, село). Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', blank=True)),
- ('submitter', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='community_submitters', verbose_name='Добавил')),
+ ('place', models.ForeignKey(to='apps.Place', verbose_name='Место', related_name='communities', on_delete=models.CASCADE, null=True, help_text='Для географически локализованных сообществ можно указать место (страна, город, село). Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', blank=True)),
+ ('submitter', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='community_submitters', on_delete=models.CASCADE, verbose_name='Добавил')),
],
options={
'verbose_name': 'Сообщество',
diff --git a/apps/migrations/0009_auto_20141004_1314.py b/pythonz/apps/migrations/0009_auto_20141004_1314.py
similarity index 60%
rename from apps/migrations/0009_auto_20141004_1314.py
rename to pythonz/apps/migrations/0009_auto_20141004_1314.py
index f3d6aad5..f133bf54 100644
--- a/apps/migrations/0009_auto_20141004_1314.py
+++ b/pythonz/apps/migrations/0009_auto_20141004_1314.py
@@ -15,49 +15,49 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='article',
name='last_editor',
- field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='article_editors', verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
+ field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='article_editors', on_delete=models.CASCADE, verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
preserve_default=True,
),
migrations.AddField(
model_name='book',
name='last_editor',
- field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='book_editors', verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
+ field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='book_editors', on_delete=models.CASCADE, verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
preserve_default=True,
),
migrations.AddField(
model_name='community',
name='last_editor',
- field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='community_editors', verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
+ field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='community_editors', on_delete=models.CASCADE, verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
preserve_default=True,
),
migrations.AddField(
model_name='event',
name='last_editor',
- field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='event_editors', verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
+ field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='event_editors', on_delete=models.CASCADE, verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
preserve_default=True,
),
migrations.AddField(
model_name='opinion',
name='last_editor',
- field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='opinion_editors', verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
+ field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='opinion_editors', on_delete=models.CASCADE, verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
preserve_default=True,
),
migrations.AddField(
model_name='place',
name='last_editor',
- field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='place_editors', verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
+ field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='place_editors', on_delete=models.CASCADE, verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
preserve_default=True,
),
migrations.AddField(
model_name='user',
name='last_editor',
- field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='user_editors', verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
+ field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='user_editors', on_delete=models.CASCADE, verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
preserve_default=True,
),
migrations.AddField(
model_name='video',
name='last_editor',
- field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='video_editors', verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
+ field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, related_name='video_editors', on_delete=models.CASCADE, verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.'),
preserve_default=True,
),
migrations.AlterField(
diff --git a/apps/migrations/0010_auto_20141004_1519.py b/pythonz/apps/migrations/0010_auto_20141004_1519.py
similarity index 100%
rename from apps/migrations/0010_auto_20141004_1519.py
rename to pythonz/apps/migrations/0010_auto_20141004_1519.py
diff --git a/apps/migrations/0011_auto_20141013_1517.py b/pythonz/apps/migrations/0011_auto_20141013_1517.py
similarity index 97%
rename from apps/migrations/0011_auto_20141013_1517.py
rename to pythonz/apps/migrations/0011_auto_20141013_1517.py
index 4afc94b8..c321173d 100644
--- a/apps/migrations/0011_auto_20141013_1517.py
+++ b/pythonz/apps/migrations/0011_auto_20141013_1517.py
@@ -31,7 +31,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='event',
name='place',
- field=models.ForeignKey(verbose_name='Место', to='apps.Place', help_text='Укажите место проведения мероприятия. Конкретный адрес следует указывать в описании. Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', blank=True, null=True, related_name='events'),
+ field=models.ForeignKey(verbose_name='Место', to='apps.Place', help_text='Укажите место проведения мероприятия. Конкретный адрес следует указывать в описании. Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', blank=True, null=True, related_name='events', on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AddField(
diff --git a/apps/migrations/0012_auto_20141018_1416.py b/pythonz/apps/migrations/0012_auto_20141018_1416.py
similarity index 92%
rename from apps/migrations/0012_auto_20141018_1416.py
rename to pythonz/apps/migrations/0012_auto_20141018_1416.py
index 4950e98e..75c6fdd8 100644
--- a/apps/migrations/0012_auto_20141018_1416.py
+++ b/pythonz/apps/migrations/0012_auto_20141018_1416.py
@@ -25,6 +25,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='event',
name='place',
- field=models.ForeignKey(help_text='Укажите место проведения мероприятия.Конкретный адрес следует указывать в описании. Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', blank=True, null=True, related_name='events', verbose_name='Место', to='apps.Place'),
+ field=models.ForeignKey(help_text='Укажите место проведения мероприятия.Конкретный адрес следует указывать в описании. Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', blank=True, null=True, related_name='events', on_delete=models.CASCADE, verbose_name='Место', to='apps.Place'),
),
]
diff --git a/apps/migrations/0013_event_fee.py b/pythonz/apps/migrations/0013_event_fee.py
similarity index 100%
rename from apps/migrations/0013_event_fee.py
rename to pythonz/apps/migrations/0013_event_fee.py
diff --git a/apps/migrations/0014_auto_20141023_1450.py b/pythonz/apps/migrations/0014_auto_20141023_1450.py
similarity index 96%
rename from apps/migrations/0014_auto_20141023_1450.py
rename to pythonz/apps/migrations/0014_auto_20141023_1450.py
index 0b32042f..99fba548 100644
--- a/apps/migrations/0014_auto_20141023_1450.py
+++ b/pythonz/apps/migrations/0014_auto_20141023_1450.py
@@ -26,9 +26,9 @@ class Migration(migrations.Migration):
('status', models.PositiveIntegerField(verbose_name='Статус', default=1, choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален')])),
('supporters_num', models.PositiveIntegerField(verbose_name='Поддержка', default=0)),
('object_id', models.PositiveIntegerField(verbose_name='ID объекта', db_index=True)),
- ('content_type', models.ForeignKey(verbose_name='Тип содержимого', related_name='discussion_opinions', to='contenttypes.ContentType')),
- ('last_editor', models.ForeignKey(verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.', related_name='discussion_editors', blank=True, to=settings.AUTH_USER_MODEL, null=True)),
- ('submitter', models.ForeignKey(verbose_name='Автор', to=settings.AUTH_USER_MODEL)),
+ ('content_type', models.ForeignKey(verbose_name='Тип содержимого', related_name='discussion_opinions', on_delete=models.CASCADE, to='contenttypes.ContentType')),
+ ('last_editor', models.ForeignKey(verbose_name='Редактор', help_text='Пользователь, последним отредактировавший объект.', related_name='discussion_editors', on_delete=models.CASCADE, blank=True, to=settings.AUTH_USER_MODEL, null=True)),
+ ('submitter', models.ForeignKey(verbose_name='Автор', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'verbose_name': 'Мнение',
diff --git a/apps/migrations/0015_auto_20141023_1500.py b/pythonz/apps/migrations/0015_auto_20141023_1500.py
similarity index 87%
rename from apps/migrations/0015_auto_20141023_1500.py
rename to pythonz/apps/migrations/0015_auto_20141023_1500.py
index 9f08b7af..2258f517 100644
--- a/apps/migrations/0015_auto_20141023_1500.py
+++ b/pythonz/apps/migrations/0015_auto_20141023_1500.py
@@ -2,7 +2,7 @@
from __future__ import unicode_literals
from django.db import models, migrations
-import apps.generics.models
+from ..generics.models import get_upload_to
from django.conf import settings
@@ -20,7 +20,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='discussion',
name='cover',
- field=models.ImageField(verbose_name='Обложка', blank=True, null=True, upload_to=apps.generics.models.get_upload_to, max_length=255),
+ field=models.ImageField(verbose_name='Обложка', blank=True, null=True, upload_to=get_upload_to, max_length=255),
preserve_default=True,
),
migrations.AddField(
@@ -50,7 +50,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='discussion',
name='content_type',
- field=models.ForeignKey(related_name='discussion_opinions', null=True, verbose_name='Тип содержимого', blank=True, to='contenttypes.ContentType'),
+ field=models.ForeignKey(related_name='discussion_opinions', on_delete=models.CASCADE, null=True, verbose_name='Тип содержимого', blank=True, to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='discussion',
@@ -60,6 +60,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='discussion',
name='submitter',
- field=models.ForeignKey(related_name='discussion_submitters', verbose_name='Добавил', to=settings.AUTH_USER_MODEL),
+ field=models.ForeignKey(related_name='discussion_submitters', on_delete=models.CASCADE, verbose_name='Добавил', to=settings.AUTH_USER_MODEL),
),
]
diff --git a/apps/migrations/0016_auto_20141023_1529.py b/pythonz/apps/migrations/0016_auto_20141023_1529.py
similarity index 86%
rename from apps/migrations/0016_auto_20141023_1529.py
rename to pythonz/apps/migrations/0016_auto_20141023_1529.py
index c6d12140..713f4636 100644
--- a/apps/migrations/0016_auto_20141023_1529.py
+++ b/pythonz/apps/migrations/0016_auto_20141023_1529.py
@@ -14,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='discussion',
name='content_type',
- field=models.ForeignKey(to='contenttypes.ContentType', null=True, blank=True, verbose_name='Тип содержимого', related_name='discussion_discussions'),
+ field=models.ForeignKey(to='contenttypes.ContentType', null=True, blank=True, verbose_name='Тип содержимого', related_name='discussion_discussions', on_delete=models.CASCADE),
),
]
diff --git a/apps/migrations/0017_auto_20141025_1001.py b/pythonz/apps/migrations/0017_auto_20141025_1001.py
similarity index 100%
rename from apps/migrations/0017_auto_20141025_1001.py
rename to pythonz/apps/migrations/0017_auto_20141025_1001.py
diff --git a/apps/migrations/0018_auto_20141108_1538.py b/pythonz/apps/migrations/0018_auto_20141108_1538.py
similarity index 93%
rename from apps/migrations/0018_auto_20141108_1538.py
rename to pythonz/apps/migrations/0018_auto_20141108_1538.py
index f82d00be..a574999c 100644
--- a/apps/migrations/0018_auto_20141108_1538.py
+++ b/pythonz/apps/migrations/0018_auto_20141108_1538.py
@@ -3,7 +3,7 @@
from django.db import models, migrations
import etc.models
-import apps.generics.models
+from ..generics.models import get_upload_to
from django.conf import settings
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
('text_src', models.TextField(help_text='Подробное описание. Здесь же следует располагать примеры кода.', verbose_name='Исходный текст')),
('title', models.CharField(help_text='Здесь следует указать название раздела справки или пакета, модуля, класса, метода, функции и т.п.', verbose_name='Название', max_length=255, unique=True)),
('description', models.TextField(help_text='Краткое описание для раздела или пакета, модуля, класса, функции и т.п.', verbose_name='Описание')),
- ('cover', models.ImageField(null=True, blank=True, max_length=255, upload_to=apps.generics.models.get_upload_to, verbose_name='Обложка')),
+ ('cover', models.ImageField(null=True, blank=True, max_length=255, upload_to=get_upload_to, verbose_name='Обложка')),
('year', models.CharField(null=True, blank=True, max_length=10, verbose_name='Год')),
('time_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('time_published', models.DateTimeField(null=True, editable=False, verbose_name='Дата публикации')),
@@ -33,10 +33,10 @@ class Migration(migrations.Migration):
('func_proto', models.CharField(null=True, blank=True, max_length=250, help_text='Для функций/методов. Описание интерфейса: my_func(arg, kwarg=None)', verbose_name='Прототип')),
('func_params', models.TextField(null=True, blank=True, help_text='Для функций/методов. Описание параметров функции.', verbose_name='Параметры')),
('func_result', models.CharField(null=True, blank=True, max_length=250, help_text='Для функций/методов. Описание результата.', verbose_name='Результат')),
- ('last_editor', models.ForeignKey(blank=True, verbose_name='Редактор', null=True, help_text='Пользователь, последним отредактировавший объект.', to=settings.AUTH_USER_MODEL, related_name='reference_editors')),
+ ('last_editor', models.ForeignKey(blank=True, verbose_name='Редактор', null=True, help_text='Пользователь, последним отредактировавший объект.', to=settings.AUTH_USER_MODEL, related_name='reference_editors', on_delete=models.CASCADE)),
('linked', models.ManyToManyField(to='apps.Reference', blank=True, help_text='Выберите объекты, имеющие отношение к данному.', related_name='linked_rel_+', verbose_name='Связанные объекты')),
- ('parent', models.ForeignKey(blank=True, verbose_name='Родитель', null=True, help_text='Укажите родительский раздел. Например, для модуля можно указать раздел справки, в которому он относится; для метода — класс.', to='apps.Reference', related_name='reference_parents')),
- ('submitter', models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name='Добавил', related_name='reference_submitters')),
+ ('parent', models.ForeignKey(blank=True, verbose_name='Родитель', null=True, help_text='Укажите родительский раздел. Например, для модуля можно указать раздел справки, в которому он относится; для метода — класс.', to='apps.Reference', related_name='reference_parents', on_delete=models.CASCADE)),
+ ('submitter', models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name='Добавил', related_name='reference_submitters', on_delete=models.CASCADE)),
],
options={
'verbose_name_plural': 'Статьи справочника',
@@ -52,7 +52,7 @@ class Migration(migrations.Migration):
('text_src', models.TextField(help_text='Обзорное, более полное описание нововведений и изменений, произошедших в версии. Без обозначения личного отношения. Личное отношение можно выразить во Мнениях. ', verbose_name='Исходный текст')),
('title', models.CharField(verbose_name='Название', max_length=255, unique=True)),
('description', models.TextField(help_text='Краткое описание основных изменений в версии.', verbose_name='Описание')),
- ('cover', models.ImageField(null=True, blank=True, max_length=255, upload_to=apps.generics.models.get_upload_to, verbose_name='Обложка')),
+ ('cover', models.ImageField(null=True, blank=True, max_length=255, upload_to=get_upload_to, verbose_name='Обложка')),
('year', models.CharField(null=True, blank=True, max_length=10, verbose_name='Год')),
('time_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('time_published', models.DateTimeField(null=True, editable=False, verbose_name='Дата публикации')),
@@ -60,9 +60,9 @@ class Migration(migrations.Migration):
('status', models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален')], default=1, verbose_name='Статус')),
('supporters_num', models.PositiveIntegerField(default=0, verbose_name='Поддержка')),
('current', models.BooleanField(db_index=True, default=False, verbose_name='Текущая')),
- ('last_editor', models.ForeignKey(blank=True, verbose_name='Редактор', null=True, help_text='Пользователь, последним отредактировавший объект.', to=settings.AUTH_USER_MODEL, related_name='version_editors')),
+ ('last_editor', models.ForeignKey(blank=True, verbose_name='Редактор', null=True, help_text='Пользователь, последним отредактировавший объект.', to=settings.AUTH_USER_MODEL, related_name='version_editors', on_delete=models.CASCADE)),
('linked', models.ManyToManyField(to='apps.Version', blank=True, help_text='Выберите объекты, имеющие отношение к данному.', related_name='linked_rel_+', verbose_name='Связанные объекты')),
- ('submitter', models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name='Добавил', related_name='version_submitters')),
+ ('submitter', models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name='Добавил', related_name='version_submitters', on_delete=models.CASCADE)),
],
options={
'verbose_name_plural': 'Версии Python',
@@ -73,13 +73,13 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='reference',
name='version_added',
- field=models.ForeignKey(blank=True, verbose_name='Добавлено в', null=True, help_text='Версия Python, для которой впервые стала актульна данная статья (версия, где впервые появился модуль, пакет, класс, функция).', to='apps.Version', related_name='reference_added'),
+ field=models.ForeignKey(blank=True, verbose_name='Добавлено в', null=True, help_text='Версия Python, для которой впервые стала актульна данная статья (версия, где впервые появился модуль, пакет, класс, функция).', to='apps.Version', related_name='reference_added', on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AddField(
model_name='reference',
name='version_deprecated',
- field=models.ForeignKey(blank=True, verbose_name='Устарело в', null=True, help_text='Версия Python, для которой впервые данная статья перестала быть актуальной (версия, где модуль, пакет, класс, функция были объявлены устаревшими).', to='apps.Version', related_name='reference_deprecated'),
+ field=models.ForeignKey(blank=True, verbose_name='Устарело в', null=True, help_text='Версия Python, для которой впервые данная статья перестала быть актуальной (версия, где модуль, пакет, класс, функция были объявлены устаревшими).', to='apps.Version', related_name='reference_deprecated', on_delete=models.CASCADE),
preserve_default=True,
),
]
diff --git a/apps/migrations/0019_auto_20141114_1603.py b/pythonz/apps/migrations/0019_auto_20141114_1603.py
similarity index 98%
rename from apps/migrations/0019_auto_20141114_1603.py
rename to pythonz/apps/migrations/0019_auto_20141114_1603.py
index 03593817..71cacd5d 100644
--- a/apps/migrations/0019_auto_20141114_1603.py
+++ b/pythonz/apps/migrations/0019_auto_20141114_1603.py
@@ -39,7 +39,7 @@ class Migration(migrations.Migration):
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL)),
+ ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'ordering': ('-history_date', '-history_id'),
@@ -73,7 +73,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='reference',
name='parent',
- field=models.ForeignKey(help_text='Укажите родительский раздел. Например, для модуля можно указать раздел справки, в которому он относится; для метода — класс.', blank=True, to='apps.Reference', verbose_name='Родитель', null=True, related_name='children'),
+ field=models.ForeignKey(help_text='Укажите родительский раздел. Например, для модуля можно указать раздел справки, в которому он относится; для метода — класс.', blank=True, to='apps.Reference', verbose_name='Родитель', null=True, related_name='children', on_delete=models.CASCADE),
),
migrations.AlterField(
model_name='reference',
diff --git a/apps/migrations/0020_historicalarticle_historicalbook_historicalcommunity_historicaldiscussion_historicalevent_historical.py b/pythonz/apps/migrations/0020_historicalarticle_historicalbook_historicalcommunity_historicaldiscussion_historicalevent_historical.py
similarity index 98%
rename from apps/migrations/0020_historicalarticle_historicalbook_historicalcommunity_historicaldiscussion_historicalevent_historical.py
rename to pythonz/apps/migrations/0020_historicalarticle_historicalbook_historicalcommunity_historicaldiscussion_historicalevent_historical.py
index 18adc990..00e11fec 100644
--- a/apps/migrations/0020_historicalarticle_historicalbook_historicalcommunity_historicaldiscussion_historicalevent_historical.py
+++ b/pythonz/apps/migrations/0020_historicalarticle_historicalbook_historicalcommunity_historicaldiscussion_historicalevent_historical.py
@@ -32,7 +32,7 @@ class Migration(migrations.Migration):
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL)),
+ ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'verbose_name': 'historical Статья',
@@ -62,7 +62,7 @@ class Migration(migrations.Migration):
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL)),
+ ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'verbose_name': 'historical Книга',
@@ -93,7 +93,7 @@ class Migration(migrations.Migration):
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL)),
+ ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'verbose_name': 'historical Сообщество',
@@ -123,7 +123,7 @@ class Migration(migrations.Migration):
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL)),
+ ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'verbose_name': 'historical Обсуждение',
@@ -159,7 +159,7 @@ class Migration(migrations.Migration):
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL)),
+ ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'verbose_name': 'historical Событие',
@@ -186,7 +186,7 @@ class Migration(migrations.Migration):
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL)),
+ ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'verbose_name': 'historical Место',
@@ -216,7 +216,7 @@ class Migration(migrations.Migration):
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL)),
+ ('history_user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'verbose_name': 'historical Видео',
diff --git a/apps/migrations/0021_partnerlink.py b/pythonz/apps/migrations/0021_partnerlink.py
similarity index 92%
rename from apps/migrations/0021_partnerlink.py
rename to pythonz/apps/migrations/0021_partnerlink.py
index 448e35a4..e44f2579 100644
--- a/apps/migrations/0021_partnerlink.py
+++ b/pythonz/apps/migrations/0021_partnerlink.py
@@ -20,7 +20,7 @@ class Migration(migrations.Migration):
('partner_alias', models.CharField(max_length=50, db_index=True, choices=[('booksru', 'books.ru')], verbose_name='Идентфикатор класса партнёра')),
('url', models.URLField(help_text='Ссылка на партнёрскую страницу без указания партнёрских данных (идентификатора).', verbose_name='Базовая ссылка')),
('description', models.CharField(max_length=255, null=True, blank=True, verbose_name='Описание')),
- ('content_type', models.ForeignKey(related_name='partnerlink_partner_links', to='contenttypes.ContentType', verbose_name='Тип содержимого')),
+ ('content_type', models.ForeignKey(related_name='partnerlink_partner_links', on_delete=models.CASCADE, to='contenttypes.ContentType', verbose_name='Тип содержимого')),
],
options={
'verbose_name_plural': 'Партнёрские ссылки',
diff --git a/apps/migrations/0022_auto_20150325_1515.py b/pythonz/apps/migrations/0022_auto_20150325_1515.py
similarity index 100%
rename from apps/migrations/0022_auto_20150325_1515.py
rename to pythonz/apps/migrations/0022_auto_20150325_1515.py
diff --git a/apps/migrations/0023_auto_20150331_1711.py b/pythonz/apps/migrations/0023_auto_20150331_1711.py
similarity index 100%
rename from apps/migrations/0023_auto_20150331_1711.py
rename to pythonz/apps/migrations/0023_auto_20150331_1711.py
diff --git a/apps/migrations/0024_auto_20150621_1206.py b/pythonz/apps/migrations/0024_auto_20150621_1206.py
similarity index 100%
rename from apps/migrations/0024_auto_20150621_1206.py
rename to pythonz/apps/migrations/0024_auto_20150621_1206.py
diff --git a/apps/migrations/0025_auto_20150625_1720.py b/pythonz/apps/migrations/0025_auto_20150625_1720.py
similarity index 97%
rename from apps/migrations/0025_auto_20150625_1720.py
rename to pythonz/apps/migrations/0025_auto_20150625_1720.py
index b81f6fca..d5af5c0e 100644
--- a/apps/migrations/0025_auto_20150625_1720.py
+++ b/pythonz/apps/migrations/0025_auto_20150625_1720.py
@@ -132,12 +132,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='vacancy',
name='last_editor',
- field=models.ForeignKey(null=True, verbose_name='Редактор', blank=True, to=settings.AUTH_USER_MODEL, related_name='vacancy_editors', help_text='Пользователь, последним отредактировавший объект.'),
+ field=models.ForeignKey(null=True, verbose_name='Редактор', blank=True, to=settings.AUTH_USER_MODEL, related_name='vacancy_editors', on_delete=models.CASCADE, help_text='Пользователь, последним отредактировавший объект.'),
),
migrations.AddField(
model_name='vacancy',
name='place',
- field=models.ForeignKey(null=True, verbose_name='Место', blank=True, to='apps.Place', related_name='vacancies'),
+ field=models.ForeignKey(null=True, verbose_name='Место', blank=True, to='apps.Place', related_name='vacancies', on_delete=models.CASCADE),
),
migrations.AlterUniqueTogether(
name='vacancy',
diff --git a/pythonz/apps/migrations/0026_auto_20150917_1619.py b/pythonz/apps/migrations/0026_auto_20150917_1619.py
new file mode 100644
index 00000000..b82fc522
--- /dev/null
+++ b/pythonz/apps/migrations/0026_auto_20150917_1619.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0025_auto_20150625_1720'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ExternalResource',
+ fields=[
+ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+ ('time_created', models.DateTimeField(verbose_name='Дата создания', auto_now_add=True)),
+ ('time_published', models.DateTimeField(editable=False, verbose_name='Дата публикации', null=True)),
+ ('time_modified', models.DateTimeField(editable=False, verbose_name='Дата редактирования', null=True)),
+ ('status', models.PositiveIntegerField(default=1, choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве')], verbose_name='Статус')),
+ ('supporters_num', models.PositiveIntegerField(default=0, verbose_name='Поддержка')),
+ ('src_alias', models.CharField(max_length=20, choices=[('pydigest', 'pythondigest.ru')], verbose_name='Идентификатор источника')),
+ ('realm_name', models.CharField(max_length=20, verbose_name='Идентификатор области на pythonz')),
+ ('url', models.URLField(unique=True, verbose_name='Страница ресурса')),
+ ('title', models.CharField(max_length=255, verbose_name='Название')),
+ ('description', models.TextField(default='', verbose_name='Описание', blank=True)),
+ ('last_editor', models.ForeignKey(help_text='Пользователь, последним отредактировавший объект.', related_name='externalresource_editors', on_delete=models.CASCADE, verbose_name='Редактор', to=settings.AUTH_USER_MODEL, blank=True, null=True)),
+ ],
+ options={
+ 'verbose_name_plural': 'Внешние ресурсы',
+ 'verbose_name': 'Внешний ресурс',
+ },
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='source',
+ field=models.PositiveIntegerField(default=1, verbose_name='Тип источника', choices=[(1, 'Написана на этом сайте'), (2, 'Соскоблена с другого сайта')], help_text='Указывает на механизм, при помощи которого статья появилась на сайте.'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='description',
+ field=models.TextField(verbose_name='Описание', help_text='Аннотация к книге, или другое краткое описание. Без обозначения личного отношения. Личное отношение можно выразить в Обсуждениях к материалу. '),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='description',
+ field=models.TextField(verbose_name='Описание', help_text='Сжатая предварительная информация о сообществе (например, направление деятельности). Без обозначения личного отношения. Личное отношение можно выразить в Обсуждениях к материалу. '),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='text_src',
+ field=models.TextField(verbose_name='Исходный текст', help_text='Без обозначения личного отношения. Личное отношение можно выразить в Обсуждениях к материалу. '),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='description',
+ field=models.TextField(verbose_name='Описание', help_text='Краткое описание события. Без обозначения личного отношения. Личное отношение можно выразить в Обсуждениях к материалу. '),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='text_src',
+ field=models.TextField(verbose_name='Исходный текст', help_text='Без обозначения личного отношения. Личное отношение можно выразить в Обсуждениях к материалу. '),
+ ),
+ migrations.AlterField(
+ model_name='historicalarticle',
+ name='source',
+ field=models.PositiveIntegerField(default=1, verbose_name='Тип источника', choices=[(1, 'Написана на этом сайте'), (2, 'Соскоблена с другого сайта')], help_text='Указывает на механизм, при помощи которого статья появилась на сайте.'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='title',
+ field=models.CharField(verbose_name='Название', unique=True, max_length=255, help_text='Здесь следует указать название раздела справки или пакета, модуля, класса, метода, функции и т.п.'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='text_src',
+ field=models.TextField(verbose_name='Исходный текст', help_text=('Обзорное, более полное описание нововведений и изменений, произошедших в версии. Без обозначения личного отношения. Личное отношение можно выразить в Обсуждениях к материалу. ',)),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='description',
+ field=models.TextField(verbose_name='Описание', help_text='Краткое описание того, о чём это видео. Без обозначения личного отношения. Личное отношение можно выразить в Обсуждениях к материалу. '),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0027_auto_20151104_0511.py b/pythonz/apps/migrations/0027_auto_20151104_0511.py
new file mode 100644
index 00000000..ebaee86e
--- /dev/null
+++ b/pythonz/apps/migrations/0027_auto_20151104_0511.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0026_auto_20150917_1619'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='externalresource',
+ options={'verbose_name_plural': 'Внешние ресурсы', 'verbose_name': 'Внешний ресурс', 'ordering': ('-time_created',)},
+ ),
+ migrations.RemoveField(
+ model_name='user',
+ name='digest_enabled',
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0028_user_profile_public.py b/pythonz/apps/migrations/0028_user_profile_public.py
new file mode 100644
index 00000000..8e21a91a
--- /dev/null
+++ b/pythonz/apps/migrations/0028_user_profile_public.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0027_auto_20151104_0511'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='profile_public',
+ field=models.BooleanField(db_index=True, default=True, verbose_name='Публичный профиль', help_text='Если выключить, то увидеть ваш профиль сможете только вы. В списках пользователей профиль значиться тоже не будет.'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0029_auto_20160321_1412.py b/pythonz/apps/migrations/0029_auto_20160321_1412.py
new file mode 100644
index 00000000..1312f2b7
--- /dev/null
+++ b/pythonz/apps/migrations/0029_auto_20160321_1412.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.2 on 2016-03-21 13:12
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0028_user_profile_public'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='historicalreference',
+ name='search_terms',
+ field=models.CharField(blank=True, default='', help_text='Дополнительные фразы, по которым можно найти данную статью, например: «список», для «list» ', max_length=500, verbose_name='Термины поиска'),
+ ),
+ migrations.AddField(
+ model_name='reference',
+ name='search_terms',
+ field=models.CharField(blank=True, default='', help_text='Дополнительные фразы, по которым можно найти данную статью, например: «список», для «list» ', max_length=500, verbose_name='Термины поиска'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='location',
+ field=models.PositiveIntegerField(choices=[(1, 'На этом сайте')], default=1, help_text='Статью можно написать прямо на этом сайте, либо сформировать статью-ссылку на внешний ресурс.', verbose_name='Расположение статьи'),
+ ),
+ migrations.AlterField(
+ model_name='historicalarticle',
+ name='location',
+ field=models.PositiveIntegerField(choices=[(1, 'На этом сайте')], default=1, help_text='Статью можно написать прямо на этом сайте, либо сформировать статью-ссылку на внешний ресурс.', verbose_name='Расположение статьи'),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='parent',
+ field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='apps.HistoricalReference'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='username',
+ field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=30, unique=True, validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.')], verbose_name='username'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0030_auto_20160711_0915.py b/pythonz/apps/migrations/0030_auto_20160711_0915.py
new file mode 100644
index 00000000..1ffcafcf
--- /dev/null
+++ b/pythonz/apps/migrations/0030_auto_20160711_0915.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.6 on 2016-07-11 07:15
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0029_auto_20160321_1412'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='historicalreference',
+ name='pep',
+ field=models.PositiveIntegerField(blank=True, help_text='Номер предложения по улучшению Питона, связанного с этой статьёй, например: 8 для PEP-8', null=True, verbose_name='PEP'),
+ ),
+ migrations.AddField(
+ model_name='reference',
+ name='pep',
+ field=models.PositiveIntegerField(blank=True, help_text='Номер предложения по улучшению Питона, связанного с этой статьёй, например: 8 для PEP-8', null=True, verbose_name='PEP'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='text_src',
+ field=models.TextField(help_text='Обзорное, более полное описание нововведений и изменений, произошедших в версии. Без обозначения личного отношения. Личное отношение можно выразить в Обсуждениях к материалу. ', verbose_name='Исходный текст'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0031_auto_20160818_1057.py b/pythonz/apps/migrations/0031_auto_20160818_1057.py
new file mode 100644
index 00000000..54accdf1
--- /dev/null
+++ b/pythonz/apps/migrations/0031_auto_20160818_1057.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.9 on 2016-08-18 08:57
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0030_auto_20160711_0915'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ReferenceMissing',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('term', models.CharField(max_length=255, unique=True, verbose_name='Термин')),
+ ('synonyms', models.TextField(blank=True, verbose_name='Синонимы')),
+ ('hits', models.PositiveIntegerField(default=0, verbose_name='Запросы')),
+ ],
+ options={
+ 'verbose_name_plural': 'Промахи справочника',
+ 'verbose_name': 'Промах справочника',
+ },
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='isbn',
+ field=models.CharField(blank=True, max_length=20, null=True, unique=True, verbose_name='ISBN'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='isbn_ebook',
+ field=models.CharField(blank=True, max_length=20, null=True, unique=True, verbose_name='ISBN эл. книги'),
+ ),
+ migrations.AlterField(
+ model_name='historicalbook',
+ name='isbn',
+ field=models.CharField(blank=True, db_index=True, max_length=20, null=True, verbose_name='ISBN'),
+ ),
+ migrations.AlterField(
+ model_name='historicalbook',
+ name='isbn_ebook',
+ field=models.CharField(blank=True, db_index=True, max_length=20, null=True, verbose_name='ISBN эл. книги'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0032_auto_20160908_1754.py b/pythonz/apps/migrations/0032_auto_20160908_1754.py
new file mode 100644
index 00000000..7d61ad16
--- /dev/null
+++ b/pythonz/apps/migrations/0032_auto_20160908_1754.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-09-08 15:54
+from __future__ import unicode_literals
+
+import django.contrib.auth.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0031_auto_20160818_1057'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='article',
+ name='url',
+ field=models.URLField(blank=True, help_text='Внешний URL, по которому доступна статья, которой вы желаете поделиться.', null=True, unique=True, verbose_name='URL статьи'),
+ ),
+ migrations.AlterField(
+ model_name='historicalarticle',
+ name='url',
+ field=models.URLField(blank=True, db_index=True, help_text='Внешний URL, по которому доступна статья, которой вы желаете поделиться.', null=True, verbose_name='URL статьи'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='username',
+ field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0033_user_twitter.py b/pythonz/apps/migrations/0033_user_twitter.py
new file mode 100644
index 00000000..bbe3c442
--- /dev/null
+++ b/pythonz/apps/migrations/0033_user_twitter.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-09-10 10:21
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0032_auto_20160908_1754'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='twitter',
+ field=models.CharField(blank=True, default='', help_text='Имя в Twitter.', max_length=100, verbose_name='Twitter'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0034_auto_20160913_1028.py b/pythonz/apps/migrations/0034_auto_20160913_1028.py
new file mode 100644
index 00000000..9457a170
--- /dev/null
+++ b/pythonz/apps/migrations/0034_auto_20160913_1028.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-09-13 08:28
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0033_user_twitter'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='version',
+ options={'ordering': ('-date',), 'verbose_name': 'Версия Python', 'verbose_name_plural': 'Версии Python'},
+ ),
+ migrations.AddField(
+ model_name='version',
+ name='date',
+ field=models.DateField(default=django.utils.timezone.now, verbose_name='Дата выпуска'),
+ preserve_default=False,
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='current',
+ field=models.BooleanField(default=False, verbose_name='Текущая'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='title',
+ field=models.CharField(help_text='Например: 2.7.12, 3.6.0', max_length=255, unique=True, verbose_name='Название'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0035_auto_20160921_0745.py b/pythonz/apps/migrations/0035_auto_20160921_0745.py
new file mode 100644
index 00000000..5710f9c0
--- /dev/null
+++ b/pythonz/apps/migrations/0035_auto_20160921_0745.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-09-21 05:45
+from __future__ import unicode_literals
+
+from ..generics.models import get_upload_to
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0034_auto_20160913_1028'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='PEP',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=255, unique=True, verbose_name='Название')),
+ ('slug', models.CharField(blank=True, max_length=200, null=True, unique=True, verbose_name='Краткое имя для URL')),
+ ('description', models.TextField(verbose_name='Описание')),
+ ('cover', models.ImageField(blank=True, max_length=255, null=True, upload_to=get_upload_to, verbose_name='Обложка')),
+ ('year', models.CharField(blank=True, max_length=10, null=True, verbose_name='Год')),
+ ('time_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
+ ('time_published', models.DateTimeField(editable=False, null=True, verbose_name='Дата публикации')),
+ ('time_modified', models.DateTimeField(editable=False, null=True, verbose_name='Дата редактирования')),
+ ('supporters_num', models.PositiveIntegerField(default=0, verbose_name='Поддержка')),
+ ('num', models.PositiveIntegerField(verbose_name='Номер')),
+ ('status', models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Действует'), (3, 'Отозвано [автором]'), (4, 'Отложено'), (5, 'Отклонено'), (6, 'Утверждено (принято; возможно не реализовано)'), (7, 'Финализировано (работа завершена; реализовано)'), (8, 'Заменено (имеется более актуальное PEP)'), (9, 'Розыгрыш на 1 апреля')], default=1, verbose_name='Статус')),
+ ('type', models.PositiveIntegerField(choices=[(1, 'Процесс'), (2, 'Стандарт'), (3, 'Информация')], default=2, verbose_name='Тип')),
+ ('last_editor', models.ForeignKey(blank=True, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pep_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор')),
+ ('linked', models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', related_name='_pep_linked_+', to='apps.PEP', verbose_name='Связанные объекты')),
+ ('replaces', models.ManyToManyField(related_name='_pep_replaces_+', to='apps.PEP', verbose_name='Поглощает')),
+ ('requires', models.ManyToManyField(related_name='_pep_requires_+', to='apps.PEP', verbose_name='Зависит от')),
+ ('submitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pep_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил')),
+ ('superseded', models.ManyToManyField(related_name='_pep_superseded_+', to='apps.PEP', verbose_name='Заменено на')),
+ ],
+ options={
+ 'verbose_name_plural': 'Список PEP',
+ 'verbose_name': 'PEP',
+ },
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='title',
+ field=models.CharField(help_text='Номер версии с двумя обязательными разрядами и третим опциональным. Например: 2.7.12, 3.6.', max_length=255, unique=True, verbose_name='Название'),
+ ),
+ migrations.AddField(
+ model_name='pep',
+ name='versions',
+ field=models.ManyToManyField(related_name='peps', to='apps.Version', verbose_name='Версии Питона'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0036_auto_20160921_0852.py b/pythonz/apps/migrations/0036_auto_20160921_0852.py
new file mode 100644
index 00000000..2b0a8e1d
--- /dev/null
+++ b/pythonz/apps/migrations/0036_auto_20160921_0852.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-09-21 06:52
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0035_auto_20160921_0745'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='pep',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0037_auto_20160921_1155.py b/pythonz/apps/migrations/0037_auto_20160921_1155.py
new file mode 100644
index 00000000..41b12d73
--- /dev/null
+++ b/pythonz/apps/migrations/0037_auto_20160921_1155.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-09-21 09:55
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0036_auto_20160921_0852'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='pep',
+ name='replaces',
+ field=models.ManyToManyField(related_name='replaced_by', to='apps.PEP', verbose_name='Поглощает'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='requires',
+ field=models.ManyToManyField(related_name='used_by', to='apps.PEP', verbose_name='Зависит от'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='superseded',
+ field=models.ManyToManyField(related_name='supersedes', to='apps.PEP', verbose_name='Заменено на'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0038_auto_20161230_1453.py b/pythonz/apps/migrations/0038_auto_20161230_1453.py
new file mode 100644
index 00000000..5971f346
--- /dev/null
+++ b/pythonz/apps/migrations/0038_auto_20161230_1453.py
@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-12-30 13:53
+from __future__ import unicode_literals
+
+from ..models import UtmReady
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import etc.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0037_auto_20160921_1155'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Person',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('text', models.TextField(verbose_name='Текст')),
+ ('text_src', models.TextField(verbose_name='Исходный текст')),
+ ('time_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
+ ('time_published', models.DateTimeField(editable=False, null=True, verbose_name='Дата публикации')),
+ ('time_modified', models.DateTimeField(editable=False, null=True, verbose_name='Дата редактирования')),
+ ('status', models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус')),
+ ('supporters_num', models.PositiveIntegerField(default=0, verbose_name='Поддержка')),
+ ('name', models.CharField(blank=True, max_length=90, verbose_name='Имя')),
+ ('name_en', models.CharField(blank=True, max_length=90, verbose_name='Имя англ.')),
+ ('aka', models.CharField(blank=True, max_length=255, verbose_name='Другие имена')),
+ ],
+ options={
+ 'verbose_name': 'Персона',
+ 'verbose_name_plural': 'Персоны',
+ },
+ bases=(UtmReady, etc.models.InheritedModel, models.Model),
+ ),
+ migrations.AlterModelOptions(
+ name='pep',
+ options={'verbose_name': 'PEP', 'verbose_name_plural': 'PEP'},
+ ),
+ migrations.AlterModelOptions(
+ name='user',
+ options={'verbose_name': 'Пользователь', 'verbose_name_plural': 'Пользователи'},
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='externalresource',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalarticle',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalbook',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalcommunity',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicaldiscussion',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalplace',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalvideo',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='replaces',
+ field=models.ManyToManyField(blank=True, related_name='replaced_by', to='apps.PEP', verbose_name='Поглощает'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='requires',
+ field=models.ManyToManyField(blank=True, related_name='used_by', to='apps.PEP', verbose_name='Зависит от'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='superseded',
+ field=models.ManyToManyField(blank=True, related_name='supersedes', to='apps.PEP', verbose_name='Заменено на'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='versions',
+ field=models.ManyToManyField(blank=True, related_name='peps', to='apps.Version', verbose_name='Версии Питона'),
+ ),
+ migrations.AlterField(
+ model_name='place',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AddField(
+ model_name='person',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='person_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AddField(
+ model_name='person',
+ name='user',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='person', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0039_pep_authors.py b/pythonz/apps/migrations/0039_pep_authors.py
new file mode 100644
index 00000000..0122d1b0
--- /dev/null
+++ b/pythonz/apps/migrations/0039_pep_authors.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-12-30 15:26
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0038_auto_20161230_1453'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='pep',
+ name='authors',
+ field=models.ManyToManyField(blank=True, related_name='peps', to='apps.Person', verbose_name='Авторы'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0040_auto_20161231_0641.py b/pythonz/apps/migrations/0040_auto_20161231_0641.py
new file mode 100644
index 00000000..06a0f6bc
--- /dev/null
+++ b/pythonz/apps/migrations/0040_auto_20161231_0641.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-12-31 05:41
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0039_pep_authors'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='externalresource',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='externalresource_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AddField(
+ model_name='historicalplace',
+ name='submitter',
+ field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddField(
+ model_name='person',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='person_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AddField(
+ model_name='place',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='place_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AddField(
+ model_name='user',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='user_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AddField(
+ model_name='vacancy',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='vacancy_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='article_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='book_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='community_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='discussion_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='event_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='pep_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='reference_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='version_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='submitter',
+ field=models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='video_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0041_auto_20170423_0536.py b/pythonz/apps/migrations/0041_auto_20170423_0536.py
new file mode 100644
index 00000000..bf10b08f
--- /dev/null
+++ b/pythonz/apps/migrations/0041_auto_20170423_0536.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-04-23 03:36
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0040_auto_20161231_0641'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='book',
+ name='authors',
+ field=models.ManyToManyField(blank=True, related_name='books', to='apps.Person', verbose_name='Авторы'),
+ ),
+ migrations.AddField(
+ model_name='video',
+ name='authors',
+ field=models.ManyToManyField(blank=True, related_name='videos', to='apps.Person', verbose_name='Авторы'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='author',
+ field=models.CharField(help_text='Предпочтительно имя и фамилия. Можно указать несколько, разделяя запятыми.', max_length=255, verbose_name='Автор'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='specialization',
+ field=models.PositiveIntegerField(choices=[(1, 'Только Python'), (4, 'В основном Python'), (2, 'Есть секция/отделение про Python'), (3, 'Есть упоминания про Python')], default=1, verbose_name='Специализация'),
+ ),
+ migrations.AlterField(
+ model_name='historicalbook',
+ name='author',
+ field=models.CharField(help_text='Предпочтительно имя и фамилия. Можно указать несколько, разделяя запятыми.', max_length=255, verbose_name='Автор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='specialization',
+ field=models.PositiveIntegerField(choices=[(1, 'Только Python'), (4, 'В основном Python'), (2, 'Есть секция/отделение про Python'), (3, 'Есть упоминания про Python')], default=1, verbose_name='Специализация'),
+ ),
+ migrations.AlterField(
+ model_name='historicalvideo',
+ name='author',
+ field=models.CharField(help_text='Предпочтительно имя и фамилия. Можно указать несколько, разделяя запятыми.', max_length=255, verbose_name='Автор'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='author',
+ field=models.CharField(help_text='Предпочтительно имя и фамилия. Можно указать несколько, разделяя запятыми.', max_length=255, verbose_name='Автор'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0042_summary.py b/pythonz/apps/migrations/0042_summary.py
new file mode 100644
index 00000000..630f3996
--- /dev/null
+++ b/pythonz/apps/migrations/0042_summary.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-06-11 10:49
+from __future__ import unicode_literals
+
+from ..models import UtmReady
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0041_auto_20170423_0536'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Summary',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('time_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
+ ('time_published', models.DateTimeField(editable=False, null=True, verbose_name='Дата публикации')),
+ ('time_modified', models.DateTimeField(editable=False, null=True, verbose_name='Дата редактирования')),
+ ('status', models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (5, 'К отложенной публикации'), (3, 'Удален'), (4, 'В архиве')], default=1, verbose_name='Статус')),
+ ('supporters_num', models.PositiveIntegerField(default=0, verbose_name='Поддержка')),
+ ('data_items', models.TextField(verbose_name='Элементы сводки')),
+ ('data_result', models.TextField(verbose_name='Результат компоновки сводки')),
+ ('last_editor', models.ForeignKey(blank=True, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='summary_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор')),
+ ('submitter', models.ForeignKey(default=111, on_delete=django.db.models.deletion.CASCADE, related_name='summary_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил')),
+ ],
+ options={
+ 'ordering': ('-time_created',),
+ 'verbose_name_plural': 'Сводки',
+ 'verbose_name': 'Сводка',
+ },
+ bases=(UtmReady, models.Model),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0043_auto_20180603_1236.py b/pythonz/apps/migrations/0043_auto_20180603_1236.py
new file mode 100644
index 00000000..b8a91815
--- /dev/null
+++ b/pythonz/apps/migrations/0043_auto_20180603_1236.py
@@ -0,0 +1,64 @@
+# Generated by Django 2.0.6 on 2018-06-03 05:36
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0042_summary'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='historicalarticle',
+ name='history_change_reason',
+ field=models.CharField(max_length=100, null=True),
+ ),
+ migrations.AddField(
+ model_name='historicalbook',
+ name='history_change_reason',
+ field=models.CharField(max_length=100, null=True),
+ ),
+ migrations.AddField(
+ model_name='historicalcommunity',
+ name='history_change_reason',
+ field=models.CharField(max_length=100, null=True),
+ ),
+ migrations.AddField(
+ model_name='historicaldiscussion',
+ name='history_change_reason',
+ field=models.CharField(max_length=100, null=True),
+ ),
+ migrations.AddField(
+ model_name='historicalevent',
+ name='history_change_reason',
+ field=models.CharField(max_length=100, null=True),
+ ),
+ migrations.AddField(
+ model_name='historicalplace',
+ name='history_change_reason',
+ field=models.CharField(max_length=100, null=True),
+ ),
+ migrations.AddField(
+ model_name='historicalreference',
+ name='history_change_reason',
+ field=models.CharField(max_length=100, null=True),
+ ),
+ migrations.AddField(
+ model_name='historicalvideo',
+ name='history_change_reason',
+ field=models.CharField(max_length=100, null=True),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='parent',
+ field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='apps.Reference'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='last_name',
+ field=models.CharField(blank=True, max_length=150, verbose_name='last name'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0044_auto_20200118_1836.py b/pythonz/apps/migrations/0044_auto_20200118_1836.py
new file mode 100644
index 00000000..58605cd9
--- /dev/null
+++ b/pythonz/apps/migrations/0044_auto_20200118_1836.py
@@ -0,0 +1,213 @@
+# Generated by Django 3.0.2 on 2020-01-18 11:36
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sitecats', '0001_initial'),
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('apps', '0043_auto_20180603_1236'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Category',
+ fields=[
+ ],
+ options={
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('sitecats.category',),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='article_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='book_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='community_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='discussion_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='event_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='externalresource',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='externalresource_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='historicalarticle',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalarticle',
+ name='submitter',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='historicalbook',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalbook',
+ name='submitter',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='historicalcommunity',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalcommunity',
+ name='place',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Для географически локализованных сообществ можно указать место (страна, город, село). Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='apps.Place', verbose_name='Место'),
+ ),
+ migrations.AlterField(
+ model_name='historicalcommunity',
+ name='submitter',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='historicaldiscussion',
+ name='content_type',
+ field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='contenttypes.ContentType', verbose_name='Тип содержимого'),
+ ),
+ migrations.AlterField(
+ model_name='historicaldiscussion',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicaldiscussion',
+ name='submitter',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='place',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Укажите место проведения мероприятия.Конкретный адрес следует указывать в описании. Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='apps.Place', verbose_name='Место'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='submitter',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='historicalplace',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalplace',
+ name='submitter',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='parent',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Укажите родительский раздел. Например, для модуля можно указать раздел справки, в которому он относится; для метода — класс.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='apps.Reference', verbose_name='Родитель'),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='submitter',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='version_added',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Версия Python, для которой впервые стала актульна данная статья (версия, где впервые появился модуль, пакет, класс, функция).', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='apps.Version', verbose_name='Добавлено в'),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='version_deprecated',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Версия Python, для которой впервые данная статья перестала быть актуальной (версия, где модуль, пакет, класс, функция были объявлены устаревшими).', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='apps.Version', verbose_name='Устарело в'),
+ ),
+ migrations.AlterField(
+ model_name='historicalvideo',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalvideo',
+ name='submitter',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='pep_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='person',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='person_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='place',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='place_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='reference_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='summary',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='summary_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='user_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='vacancy_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='version_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='video_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0045_auto_20200125_1730.py b/pythonz/apps/migrations/0045_auto_20200125_1730.py
new file mode 100644
index 00000000..e48a74eb
--- /dev/null
+++ b/pythonz/apps/migrations/0045_auto_20200125_1730.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.2 on 2020-01-25 10:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0044_auto_20200118_1836'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='summary',
+ name='data_items',
+ field=models.TextField(verbose_name='Текущие элементы сводки'),
+ ),
+ migrations.AlterField(
+ model_name='summary',
+ name='data_result',
+ field=models.TextField(help_text='Данные для фильтрации элементов сводки при последующих обращениях к ресурсу.', verbose_name='Результат для фильтрации сводки'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0046_auto_20200223_1639.py b/pythonz/apps/migrations/0046_auto_20200223_1639.py
new file mode 100644
index 00000000..09046f69
--- /dev/null
+++ b/pythonz/apps/migrations/0046_auto_20200223_1639.py
@@ -0,0 +1,143 @@
+# Generated by Django 3.0.3 on 2020-02-23 09:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0045_auto_20200125_1730'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='article',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='specialization',
+ field=models.PositiveIntegerField(choices=[(1, 'Только Python'), (2, 'В основном Python'), (3, 'Есть секция/отделение про Python'), (4, 'Есть упоминания про Python')], default=1, verbose_name='Специализация'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='type',
+ field=models.PositiveIntegerField(choices=[(1, 'Встреча'), (2, 'Лекция'), (3, 'Конференция'), (4, 'Спринт')], default=1, verbose_name='Тип'),
+ ),
+ migrations.AlterField(
+ model_name='externalresource',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalarticle',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalbook',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalcommunity',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicaldiscussion',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='specialization',
+ field=models.PositiveIntegerField(choices=[(1, 'Только Python'), (2, 'В основном Python'), (3, 'Есть секция/отделение про Python'), (4, 'Есть упоминания про Python')], default=1, verbose_name='Специализация'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='type',
+ field=models.PositiveIntegerField(choices=[(1, 'Встреча'), (2, 'Лекция'), (3, 'Конференция'), (4, 'Спринт')], default=1, verbose_name='Тип'),
+ ),
+ migrations.AlterField(
+ model_name='historicalplace',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='historicalvideo',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='person',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='place',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='summary',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0047_auto_20200224_1208.py b/pythonz/apps/migrations/0047_auto_20200224_1208.py
new file mode 100644
index 00000000..c9c6b01f
--- /dev/null
+++ b/pythonz/apps/migrations/0047_auto_20200224_1208.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.0.3 on 2020-02-24 05:08
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0046_auto_20200223_1639'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='vacancy',
+ name='place',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lnk_vacancy', to='apps.Place', verbose_name='Место'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='url_site',
+ field=models.URLField(blank=True, null=True, verbose_name='Страница в сети'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0048_auto_20200224_1212.py b/pythonz/apps/migrations/0048_auto_20200224_1212.py
new file mode 100644
index 00000000..3442a037
--- /dev/null
+++ b/pythonz/apps/migrations/0048_auto_20200224_1212.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.0.3 on 2020-02-24 05:12
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0047_auto_20200224_1208'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='vacancy',
+ old_name='url_site',
+ new_name='url',
+ ),
+
+ ]
diff --git a/pythonz/apps/migrations/0049_auto_20200224_1216.py b/pythonz/apps/migrations/0049_auto_20200224_1216.py
new file mode 100644
index 00000000..43e10203
--- /dev/null
+++ b/pythonz/apps/migrations/0049_auto_20200224_1216.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.0.3 on 2020-02-24 05:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0048_auto_20200224_1212'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='vacancy',
+ name='src_alias',
+ field=models.CharField(blank=True, choices=[('hh', 'hh.ru')], max_length=20, null=True, verbose_name='Идентификатор источника'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='src_id',
+ field=models.CharField(blank=True, max_length=50, null=True, verbose_name='ID в источнике'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='src_place_id',
+ field=models.CharField(db_index=True, default='', max_length=20, verbose_name='ID места в источнике'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='src_place_name',
+ field=models.CharField(default='', max_length=255, verbose_name='Название места в источнике'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0050_auto_20200224_1353.py b/pythonz/apps/migrations/0050_auto_20200224_1353.py
new file mode 100644
index 00000000..2921b918
--- /dev/null
+++ b/pythonz/apps/migrations/0050_auto_20200224_1353.py
@@ -0,0 +1,88 @@
+# Generated by Django 3.0.3 on 2020-02-24 06:53
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0049_auto_20200224_1216'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='event',
+ name='src_alias',
+ field=models.CharField(blank=True, choices=[('pyglob', 'Официальный календарь'), ('pyuser', 'Календарь сообществ')], max_length=20, null=True, verbose_name='Идентификатор источника'),
+ ),
+ migrations.AddField(
+ model_name='event',
+ name='src_id',
+ field=models.CharField(blank=True, max_length=50, null=True, verbose_name='ID в источнике'),
+ ),
+ migrations.AddField(
+ model_name='event',
+ name='src_place_id',
+ field=models.CharField(db_index=True, default='', max_length=20, verbose_name='ID места в источнике'),
+ ),
+ migrations.AddField(
+ model_name='event',
+ name='src_place_name',
+ field=models.CharField(default='', max_length=255, verbose_name='Название места в источнике'),
+ ),
+ migrations.AddField(
+ model_name='historicalevent',
+ name='src_alias',
+ field=models.CharField(blank=True, choices=[('pyglob', 'Официальный календарь'), ('pyuser', 'Календарь сообществ')], max_length=20, null=True, verbose_name='Идентификатор источника'),
+ ),
+ migrations.AddField(
+ model_name='historicalevent',
+ name='src_id',
+ field=models.CharField(blank=True, max_length=50, null=True, verbose_name='ID в источнике'),
+ ),
+ migrations.AddField(
+ model_name='historicalevent',
+ name='src_place_id',
+ field=models.CharField(db_index=True, default='', max_length=20, verbose_name='ID места в источнике'),
+ ),
+ migrations.AddField(
+ model_name='historicalevent',
+ name='src_place_name',
+ field=models.CharField(default='', max_length=255, verbose_name='Название места в источнике'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='description',
+ field=models.TextField(verbose_name='Описание'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='fee',
+ field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name='Участие платное'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='place',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lnk_event', to='apps.Place', verbose_name='Место'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='text_src',
+ field=models.TextField(verbose_name='Исходный текст'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='fee',
+ field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name='Участие платное'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='place',
+ field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='apps.Place', verbose_name='Место'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='vacancy',
+ unique_together=set(),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0051_auto_20200225_1824.py b/pythonz/apps/migrations/0051_auto_20200225_1824.py
new file mode 100644
index 00000000..62f99fb9
--- /dev/null
+++ b/pythonz/apps/migrations/0051_auto_20200225_1824.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.0.3 on 2020-02-25 11:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0050_auto_20200224_1353'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='event',
+ name='src_place_id',
+ field=models.CharField(db_index=True, default='', max_length=50, verbose_name='ID места в источнике'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='type',
+ field=models.PositiveIntegerField(choices=[(1, 'Встреча'), (2, 'Конференция'), (3, 'Лекция'), (4, 'Спринт')], default=1, verbose_name='Тип'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='src_place_id',
+ field=models.CharField(db_index=True, default='', max_length=50, verbose_name='ID места в источнике'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='type',
+ field=models.PositiveIntegerField(choices=[(1, 'Встреча'), (2, 'Конференция'), (3, 'Лекция'), (4, 'Спринт')], default=1, verbose_name='Тип'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='src_place_id',
+ field=models.CharField(db_index=True, default='', max_length=50, verbose_name='ID места в источнике'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0052_auto_20200228_1857.py b/pythonz/apps/migrations/0052_auto_20200228_1857.py
new file mode 100644
index 00000000..a7960c24
--- /dev/null
+++ b/pythonz/apps/migrations/0052_auto_20200228_1857.py
@@ -0,0 +1,96 @@
+# Generated by Django 3.0.3 on 2020-02-28 11:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0051_auto_20200225_1824'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='article',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='historicalarticle',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='historicalbook',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='historicalcommunity',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='historicaldiscussion',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='historicalvideo',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='title',
+ field=models.CharField(help_text='Здесь следует указать название раздела справки или пакета, модуля, класса, метода, функции и т.п.', max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='title',
+ field=models.CharField(help_text='Номер версии с двумя обязательными разрядами и третим опциональным. Например: 2.7.12, 3.6.', max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='title',
+ field=models.CharField(max_length=255, verbose_name='Название'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='event',
+ unique_together={('src_alias', 'src_id')},
+ ),
+ migrations.AlterUniqueTogether(
+ name='vacancy',
+ unique_together={('src_alias', 'src_id')},
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0053_auto_20200404_1458.py b/pythonz/apps/migrations/0053_auto_20200404_1458.py
new file mode 100644
index 00000000..7a5dd009
--- /dev/null
+++ b/pythonz/apps/migrations/0053_auto_20200404_1458.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.0.4 on 2020-04-04 07:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0052_auto_20200228_1857'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='version',
+ name='current',
+ ),
+ migrations.AddField(
+ model_name='version',
+ name='date_till',
+ field=models.DateField(blank=True, null=True, verbose_name='Окончание поддержки'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0054_auto_20200421_1834.py b/pythonz/apps/migrations/0054_auto_20200421_1834.py
new file mode 100644
index 00000000..234d3098
--- /dev/null
+++ b/pythonz/apps/migrations/0054_auto_20200421_1834.py
@@ -0,0 +1,251 @@
+# Generated by Django 3.0.5 on 2020-04-21 11:34
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('apps', '0053_auto_20200404_1458'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='article',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='article_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='article_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='book_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='book_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='community_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='place',
+ field=models.ForeignKey(blank=True, help_text='Для географически локализованных сообществ можно указать место (страна, город, село). Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='communities', to='apps.Place', verbose_name='Место'),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='community_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='content_type',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='discussion_discussions', to='contenttypes.ContentType', verbose_name='Тип содержимого'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='discussion_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='discussion_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='event_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='place',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lnk_event', to='apps.Place', verbose_name='Место'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='event_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='externalresource',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='externalresource_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='externalresource',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='externalresource_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='historicalarticle',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalbook',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalcommunity',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicaldiscussion',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalplace',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalvideo',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, db_constraint=False, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='pep_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='pep_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='person',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='person_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='person',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='person_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='person',
+ name='user',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='person', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'),
+ ),
+ migrations.AlterField(
+ model_name='place',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='place_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='place',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='place_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='reference_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='parent',
+ field=models.ForeignKey(blank=True, help_text='Укажите родительский раздел. Например, для модуля можно указать раздел справки, в которому он относится; для метода — класс.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='apps.Reference', verbose_name='Родитель'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='reference_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='version_added',
+ field=models.ForeignKey(blank=True, help_text='Версия Python, для которой впервые стала актульна данная статья (версия, где впервые появился модуль, пакет, класс, функция).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reference_added', to='apps.Version', verbose_name='Добавлено в'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='version_deprecated',
+ field=models.ForeignKey(blank=True, help_text='Версия Python, для которой впервые данная статья перестала быть актуальной (версия, где модуль, пакет, класс, функция были объявлены устаревшими).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reference_deprecated', to='apps.Version', verbose_name='Устарело в'),
+ ),
+ migrations.AlterField(
+ model_name='summary',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='summary_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='summary',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='summary_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='user_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='place',
+ field=models.ForeignKey(blank=True, help_text='Место вашего пребывания (страна, город, село). Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='apps.Place', verbose_name='Место'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='user_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='vacancy_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='place',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lnk_vacancy', to='apps.Place', verbose_name='Место'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='vacancy_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='version_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='version_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='video_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='video_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0055_auto_20201031_2102.py b/pythonz/apps/migrations/0055_auto_20201031_2102.py
new file mode 100644
index 00000000..cf99c61c
--- /dev/null
+++ b/pythonz/apps/migrations/0055_auto_20201031_2102.py
@@ -0,0 +1,105 @@
+# Generated by Django 3.1.2 on 2020-10-31 14:02
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import pythonz.apps.generics.models
+import simple_history.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0054_auto_20200421_1834'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='book',
+ name='author',
+ field=models.CharField(help_text='Предпочтительно имя и фамилия. Можно указать несколько, разделяя запятыми.[u:<ид>:<имя>] формирует ссылку на профиль пользователя pythonz. Например: [u:1:идле].', max_length=255, verbose_name='Автор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalbook',
+ name='author',
+ field=models.CharField(help_text='Предпочтительно имя и фамилия. Можно указать несколько, разделяя запятыми.[u:<ид>:<имя>] формирует ссылку на профиль пользователя pythonz. Например: [u:1:идле].', max_length=255, verbose_name='Автор'),
+ ),
+ migrations.AlterField(
+ model_name='historicalvideo',
+ name='author',
+ field=models.CharField(help_text='Предпочтительно имя и фамилия. Можно указать несколько, разделяя запятыми.[u:<ид>:<имя>] формирует ссылку на профиль пользователя pythonz. Например: [u:1:идле].', max_length=255, verbose_name='Автор'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='first_name',
+ field=models.CharField(blank=True, max_length=150, verbose_name='first name'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='author',
+ field=models.CharField(help_text='Предпочтительно имя и фамилия. Можно указать несколько, разделяя запятыми.[u:<ид>:<имя>] формирует ссылку на профиль пользователя pythonz. Например: [u:1:идле].', max_length=255, verbose_name='Автор'),
+ ),
+ migrations.CreateModel(
+ name='HistoricalApp',
+ fields=[
+ ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
+ ('author', models.CharField(help_text='Предпочтительно имя и фамилия. Можно указать несколько, разделяя запятыми.[u:<ид>:<имя>] формирует ссылку на профиль пользователя pythonz. Например: [u:1:идле].', max_length=255, verbose_name='Автор')),
+ ('text', models.TextField(verbose_name='Текст')),
+ ('text_src', models.TextField(verbose_name='Исходный текст')),
+ ('title', models.CharField(max_length=255, verbose_name='Название')),
+ ('slug', models.CharField(blank=True, db_index=True, max_length=200, null=True, verbose_name='Краткое имя для URL')),
+ ('description', models.TextField(verbose_name='Описание')),
+ ('cover', models.TextField(blank=True, max_length=255, null=True, verbose_name='Обложка')),
+ ('year', models.CharField(blank=True, max_length=10, null=True, verbose_name='Год')),
+ ('time_created', models.DateTimeField(blank=True, editable=False, verbose_name='Дата создания')),
+ ('time_published', models.DateTimeField(editable=False, null=True, verbose_name='Дата публикации')),
+ ('time_modified', models.DateTimeField(editable=False, null=True, verbose_name='Дата редактирования')),
+ ('status', models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус')),
+ ('supporters_num', models.PositiveIntegerField(default=0, verbose_name='Поддержка')),
+ ('repo', models.URLField(blank=True, db_index=True, help_text='URL, по которому доступен исходный код приложения.', null=True, verbose_name='Репозиторий')),
+ ('downloads', models.JSONField(default=dict, verbose_name='Загрузки')),
+ ('history_id', models.AutoField(primary_key=True, serialize=False)),
+ ('history_date', models.DateTimeField()),
+ ('history_change_reason', models.CharField(max_length=100, null=True)),
+ ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
+ ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+ ('last_editor', models.ForeignKey(blank=True, db_constraint=False, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Редактор')),
+ ('submitter', models.ForeignKey(blank=True, db_constraint=False, default=1, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Добавил')),
+ ],
+ options={
+ 'verbose_name': 'historical Приложение',
+ 'ordering': ('-history_date', '-history_id'),
+ 'get_latest_by': 'history_date',
+ },
+ bases=(simple_history.models.HistoricalChanges, models.Model),
+ ),
+ migrations.CreateModel(
+ name='App',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('author', models.CharField(help_text='Предпочтительно имя и фамилия. Можно указать несколько, разделяя запятыми.[u:<ид>:<имя>] формирует ссылку на профиль пользователя pythonz. Например: [u:1:идле].', max_length=255, verbose_name='Автор')),
+ ('text', models.TextField(verbose_name='Текст')),
+ ('text_src', models.TextField(verbose_name='Исходный текст')),
+ ('title', models.CharField(max_length=255, verbose_name='Название')),
+ ('slug', models.CharField(blank=True, max_length=200, null=True, unique=True, verbose_name='Краткое имя для URL')),
+ ('description', models.TextField(verbose_name='Описание')),
+ ('cover', models.ImageField(blank=True, max_length=255, null=True, upload_to=pythonz.apps.generics.models.get_upload_to, verbose_name='Обложка')),
+ ('year', models.CharField(blank=True, max_length=10, null=True, verbose_name='Год')),
+ ('time_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
+ ('time_published', models.DateTimeField(editable=False, null=True, verbose_name='Дата публикации')),
+ ('time_modified', models.DateTimeField(editable=False, null=True, verbose_name='Дата редактирования')),
+ ('status', models.PositiveIntegerField(choices=[(1, 'Черновик'), (2, 'Опубликован'), (3, 'Удален'), (4, 'В архиве'), (5, 'К отложенной публикации')], default=1, verbose_name='Статус')),
+ ('supporters_num', models.PositiveIntegerField(default=0, verbose_name='Поддержка')),
+ ('repo', models.URLField(blank=True, help_text='URL, по которому доступен исходный код приложения.', null=True, unique=True, verbose_name='Репозиторий')),
+ ('downloads', models.JSONField(default=dict, verbose_name='Загрузки')),
+ ('authors', models.ManyToManyField(blank=True, related_name='apps', to='apps.Person', verbose_name='Авторы')),
+ ('last_editor', models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='app_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор')),
+ ('linked', models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', related_name='_app_linked_+', to='apps.App', verbose_name='Связанные объекты')),
+ ('submitter', models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='app_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил')),
+ ],
+ options={
+ 'verbose_name': 'Приложение',
+ 'verbose_name_plural': 'Приложения',
+ },
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0056_auto_20201119_1842.py b/pythonz/apps/migrations/0056_auto_20201119_1842.py
new file mode 100644
index 00000000..9148942f
--- /dev/null
+++ b/pythonz/apps/migrations/0056_auto_20201119_1842.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.1.2 on 2020-11-19 11:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0055_auto_20201031_2102'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='karma',
+ field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='Карма'),
+ ),
+ migrations.AlterField(
+ model_name='app',
+ name='description',
+ field=models.TextField(help_text='Краткое описание назначения приложения.', verbose_name='Описание'),
+ ),
+ migrations.AlterField(
+ model_name='app',
+ name='text_src',
+ field=models.TextField(help_text='Более подробное описание назначения и функциональных особенностей приложения.', verbose_name='Исходный текст'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0057_auto_20210523_0835.py b/pythonz/apps/migrations/0057_auto_20210523_0835.py
new file mode 100644
index 00000000..5ebbfe7a
--- /dev/null
+++ b/pythonz/apps/migrations/0057_auto_20210523_0835.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.3 on 2021-05-23 01:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0056_auto_20201119_1842'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='article',
+ name='nofollow',
+ field=models.BooleanField(default=False, verbose_name='Без перехода по ссылкам.'),
+ ),
+ migrations.AddField(
+ model_name='historicalarticle',
+ name='nofollow',
+ field=models.BooleanField(default=False, verbose_name='Без перехода по ссылкам.'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0058_preferences_alter_historicalapp_options_and_more.py b/pythonz/apps/migrations/0058_preferences_alter_historicalapp_options_and_more.py
new file mode 100644
index 00000000..52f7728e
--- /dev/null
+++ b/pythonz/apps/migrations/0058_preferences_alter_historicalapp_options_and_more.py
@@ -0,0 +1,342 @@
+# Generated by Django 4.1.7 on 2023-04-20 11:29
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('apps', '0057_auto_20210523_0835'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='historicalapp',
+ options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Приложение', 'verbose_name_plural': 'historical Приложения'},
+ ),
+ migrations.AlterModelOptions(
+ name='historicalarticle',
+ options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Статья', 'verbose_name_plural': 'historical Статьи'},
+ ),
+ migrations.AlterModelOptions(
+ name='historicalbook',
+ options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Книга', 'verbose_name_plural': 'historical Книги'},
+ ),
+ migrations.AlterModelOptions(
+ name='historicalcommunity',
+ options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Сообщество', 'verbose_name_plural': 'historical Сообщества'},
+ ),
+ migrations.AlterModelOptions(
+ name='historicaldiscussion',
+ options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Обсуждение', 'verbose_name_plural': 'historical Обсуждения'},
+ ),
+ migrations.AlterModelOptions(
+ name='historicalevent',
+ options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Событие', 'verbose_name_plural': 'historical События'},
+ ),
+ migrations.AlterModelOptions(
+ name='historicalplace',
+ options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Место', 'verbose_name_plural': 'historical Места'},
+ ),
+ migrations.AlterModelOptions(
+ name='historicalreference',
+ options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Статья справочника', 'verbose_name_plural': 'historical Справочник'},
+ ),
+ migrations.AlterModelOptions(
+ name='historicalvideo',
+ options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Видео', 'verbose_name_plural': 'historical Видео'},
+ ),
+ migrations.AlterField(
+ model_name='app',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='app',
+ name='linked',
+ field=models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', to='apps.app', verbose_name='Связанные объекты'),
+ ),
+ migrations.AlterField(
+ model_name='app',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='linked',
+ field=models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', to='apps.article', verbose_name='Связанные объекты'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='linked',
+ field=models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', to='apps.book', verbose_name='Связанные объекты'),
+ ),
+ migrations.AlterField(
+ model_name='book',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='linked',
+ field=models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', to='apps.community', verbose_name='Связанные объекты'),
+ ),
+ migrations.AlterField(
+ model_name='community',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='content_type',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_discussions', to='contenttypes.contenttype', verbose_name='Тип содержимого'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='linked',
+ field=models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', to='apps.discussion', verbose_name='Связанные объекты'),
+ ),
+ migrations.AlterField(
+ model_name='discussion',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='linked',
+ field=models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', to='apps.event', verbose_name='Связанные объекты'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='place',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lnk_%(class)s', to='apps.place', verbose_name='Место'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='externalresource',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='externalresource',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='historicalapp',
+ name='history_date',
+ field=models.DateTimeField(db_index=True),
+ ),
+ migrations.AlterField(
+ model_name='historicalarticle',
+ name='history_date',
+ field=models.DateTimeField(db_index=True),
+ ),
+ migrations.AlterField(
+ model_name='historicalbook',
+ name='history_date',
+ field=models.DateTimeField(db_index=True),
+ ),
+ migrations.AlterField(
+ model_name='historicalcommunity',
+ name='history_date',
+ field=models.DateTimeField(db_index=True),
+ ),
+ migrations.AlterField(
+ model_name='historicaldiscussion',
+ name='history_date',
+ field=models.DateTimeField(db_index=True),
+ ),
+ migrations.AlterField(
+ model_name='historicalevent',
+ name='history_date',
+ field=models.DateTimeField(db_index=True),
+ ),
+ migrations.AlterField(
+ model_name='historicalplace',
+ name='history_date',
+ field=models.DateTimeField(db_index=True),
+ ),
+ migrations.AlterField(
+ model_name='historicalreference',
+ name='history_date',
+ field=models.DateTimeField(db_index=True),
+ ),
+ migrations.AlterField(
+ model_name='historicalvideo',
+ name='history_date',
+ field=models.DateTimeField(db_index=True),
+ ),
+ migrations.AlterField(
+ model_name='partnerlink',
+ name='content_type',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_partner_links', to='contenttypes.contenttype', verbose_name='Тип содержимого'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='linked',
+ field=models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', to='apps.pep', verbose_name='Связанные объекты'),
+ ),
+ migrations.AlterField(
+ model_name='pep',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='person',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='person',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='place',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='place',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='linked',
+ field=models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', to='apps.reference', verbose_name='Связанные объекты'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='version_added',
+ field=models.ForeignKey(blank=True, help_text='Версия Python, для которой впервые стала актульна данная статья (версия, где впервые появился модуль, пакет, класс, функция).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_added', to='apps.version', verbose_name='Добавлено в'),
+ ),
+ migrations.AlterField(
+ model_name='reference',
+ name='version_deprecated',
+ field=models.ForeignKey(blank=True, help_text='Версия Python, для которой впервые данная статья перестала быть актуальной (версия, где модуль, пакет, класс, функция были объявлены устаревшими).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_deprecated', to='apps.version', verbose_name='Устарело в'),
+ ),
+ migrations.AlterField(
+ model_name='summary',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='summary',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='profile_public',
+ field=models.BooleanField(db_index=True, default=False, help_text='Если выключить, то увидеть ваш профиль сможете только вы. В списках пользователей профиль значиться тоже не будет.', verbose_name='Публичный профиль'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='place',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lnk_%(class)s', to='apps.place', verbose_name='Место'),
+ ),
+ migrations.AlterField(
+ model_name='vacancy',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='linked',
+ field=models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', to='apps.version', verbose_name='Связанные объекты'),
+ ),
+ migrations.AlterField(
+ model_name='version',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='last_editor',
+ field=models.ForeignKey(blank=True, default=1, help_text='Пользователь, последним отредактировавший объект.', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_editors', to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='linked',
+ field=models.ManyToManyField(blank=True, help_text='Выберите объекты, имеющие отношение к данному.', to='apps.video', verbose_name='Связанные объекты'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='submitter',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='%(class)s_submitters', to=settings.AUTH_USER_MODEL, verbose_name='Добавил'),
+ ),
+ ]
diff --git a/pythonz/apps/migrations/0059_preferences_remove_user_comments_enabled_and_more.py b/pythonz/apps/migrations/0059_preferences_remove_user_comments_enabled_and_more.py
new file mode 100644
index 00000000..1eec2030
--- /dev/null
+++ b/pythonz/apps/migrations/0059_preferences_remove_user_comments_enabled_and_more.py
@@ -0,0 +1,47 @@
+# Generated by Django 4.1.7 on 2023-04-23 01:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('apps', '0058_preferences_alter_historicalapp_options_and_more'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Preferences',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('socks5_proxy', models.TextField(default='')),
+ ('google_api_key', models.TextField(default='not_a_secret')),
+ ('yandex_search_id', models.TextField(default='not_a_secret')),
+ ('telegram_bot_token', models.TextField(default='not_a_secret')),
+ ('telegram_group', models.TextField(default='group_id or @channel_name')),
+ ('vk_access_token', models.TextField(default='not_a_secret')),
+ ('vk_group', models.TextField(default='group id prefixed with -')),
+ ],
+ options={
+ 'verbose_name': 'Preference',
+ 'verbose_name_plural': 'Preferences',
+ 'managed': False,
+ },
+ ),
+ migrations.RemoveField(
+ model_name='user',
+ name='comments_enabled',
+ ),
+ migrations.RemoveField(
+ model_name='user',
+ name='disqus_category_id',
+ ),
+ migrations.RemoveField(
+ model_name='user',
+ name='disqus_shortname',
+ ),
+ migrations.RemoveField(
+ model_name='user',
+ name='twitter',
+ ),
+ ]
diff --git a/data/db/empty b/pythonz/apps/migrations/__init__.py
similarity index 100%
rename from data/db/empty
rename to pythonz/apps/migrations/__init__.py
diff --git a/pythonz/apps/models/__init__.py b/pythonz/apps/models/__init__.py
new file mode 100644
index 00000000..7ec40d2a
--- /dev/null
+++ b/pythonz/apps/models/__init__.py
@@ -0,0 +1,21 @@
+from sitecats.models import ModelWithCategory
+
+from .app import App
+from .article import Article
+from .book import Book
+from .category import Category
+from .community import Community
+from .discussion import Discussion, ModelWithDiscussions
+from .event import Event
+from .extrenal import ExternalResource
+from .partner import ModelWithPartnerLinks, PartnerLink
+from .pep import PEP
+from .person import Person, PersonsLinked
+from .place import Place
+from .reference import Reference, ReferenceMissing
+from .shared import UtmReady
+from .summary import Summary
+from .user import User
+from .vacancy import Vacancy
+from .version import Version
+from .video import Video
diff --git a/pythonz/apps/models/app.py b/pythonz/apps/models/app.py
new file mode 100644
index 00000000..9c574271
--- /dev/null
+++ b/pythonz/apps/models/app.py
@@ -0,0 +1,140 @@
+from random import choice
+from time import sleep
+
+from django.db import models
+from django.db.models import Q, QuerySet
+from etc.models import InheritedModel
+from simple_history.models import HistoricalRecords
+from sitecats.models import ModelWithCategory
+
+from ..generics.models import CommonEntityModel, ModelWithAuthorAndTranslator, ModelWithCompiledText, RealmBaseModel
+from ..integration.pypistats import get_for_package
+from .discussion import ModelWithDiscussions
+from .person import PersonsLinked
+
+
+class App(
+ InheritedModel, RealmBaseModel, CommonEntityModel, ModelWithDiscussions, ModelWithCategory,
+ ModelWithCompiledText, ModelWithAuthorAndTranslator, PersonsLinked):
+ """Модель сущности `Приложение`."""
+
+ COVER_UPLOAD_TO: str = 'apps'
+
+ paginator_defer: list[str] = ['downloads', 'text', 'text_src']
+
+ repo = models.URLField(
+ 'Репозиторий', null=True, blank=True, unique=True,
+ help_text='URL, по которому доступен исходный код приложения.')
+
+ authors = models.ManyToManyField('Person', verbose_name='Авторы', related_name='apps', blank=True)
+ downloads = models.JSONField('Загрузки', default=dict)
+
+ slug_pick: bool = True
+ translator = None
+
+ history = HistoricalRecords()
+
+ persons_fields: list[str] = ['authors']
+
+ class Meta:
+
+ verbose_name = 'Приложение'
+ verbose_name_plural = 'Приложения'
+
+ class Fields:
+
+ title = 'Название приложения'
+ slug = 'Назание на PyPI'
+
+ description = {
+ 'help_text': 'Краткое описание назначения приложения.',
+ }
+
+ text_src = {
+ 'verbose_name': 'Описание',
+ 'help_text': 'Более подробное описание назначения и функциональных особенностей приложения.',
+ }
+
+ @classmethod
+ def find(cls, *search_terms: str) -> QuerySet:
+ """Ищет указанный текст в приложениях. Возвращает QuerySet.
+
+ :param search_terms: Строка для поиска.
+
+ """
+ q = Q()
+
+ for term in search_terms:
+
+ if not term:
+ continue
+
+ q |= Q(slug__icontains=term)
+
+ return cls.objects.published().filter(q).order_by('time_published')
+
+ @classmethod
+ def actualize_downloads(cls, qs: QuerySet = None) -> int:
+ """Актуализирует данные о загрузках приложений."""
+
+ if qs is None:
+ qs = cls.objects.published()
+
+ apps = qs.filter(slug__isnull=False)
+
+ updated = []
+
+ for idx, app in enumerate(apps):
+
+ if idx > 0:
+ # У pypistats.org есть ограничени по кол-ву запросов с IP.
+ # Пробуем быть особо вежливыми.
+ sleep(choice((0.3, 0.8, 1.2)))
+
+ if app.update_downloads():
+ updated.append(app)
+
+ if updated:
+ cls.objects.bulk_update(updated, fields=['downloads'])
+
+ return len(updated)
+
+ @property
+ def turbo_content(self) -> str:
+ return self.make_html(self.description)
+
+ @property
+ def downloads_map(self) -> dict:
+ """Данные о загрузках в виде словаря."""
+ return {self.slug: self.downloads}
+
+ @property
+ def github_ident(self) -> str:
+ repo = self.repo or ''
+
+ prefix = 'https://github.com/'
+
+ if not repo.startswith(prefix):
+ return ''
+
+ return repo.replace(prefix, '', 1).rstrip('/')
+
+ def on_publish(self):
+ self.update_downloads()
+
+ def update_downloads(self) -> bool:
+ """Обновляет данные о загрузках пакета."""
+ slug = self.slug
+
+ if not slug:
+ return False
+
+ data = get_for_package(slug)
+
+ downloads = self.downloads
+ downloads.update(data)
+
+ self.downloads = dict(sorted((
+ item for item in downloads.items()), key=lambda item: item[0]))
+
+ return bool(data)
diff --git a/pythonz/apps/models/article.py b/pythonz/apps/models/article.py
new file mode 100644
index 00000000..9971eb02
--- /dev/null
+++ b/pythonz/apps/models/article.py
@@ -0,0 +1,101 @@
+from enum import unique
+
+from django.db import models
+from etc.models import InheritedModel
+from simple_history.models import HistoricalRecords
+from sitecats.models import ModelWithCategory
+
+from ..exceptions import RemoteSourceError
+from ..generics.models import CommonEntityModel, ModelWithCompiledText, RealmBaseModel
+from ..integration.utils import scrape_page
+from .discussion import ModelWithDiscussions
+from .shared import UtmReady
+
+
+class Article(
+ UtmReady, InheritedModel, RealmBaseModel, CommonEntityModel, ModelWithDiscussions, ModelWithCategory,
+ ModelWithCompiledText):
+ """Модель сущности `Статья`."""
+
+ allow_edit_anybody: bool = False
+ paginator_defer: list[str] = ['url', 'text', 'text_src']
+
+ @unique
+ class Location(models.IntegerChoices):
+
+ INTERNAL = 1, 'На этом сайте'
+ EXTERNAL = 2, 'На другом сайте'
+
+ @unique
+ class Source(models.IntegerChoices):
+
+ HANDMADE = 1, 'Написана на этом сайте'
+ SCRAPING = 2, 'Соскоблена с другого сайта'
+
+ source = models.PositiveIntegerField(
+ 'Тип источника', choices=Source.choices, default=Source.HANDMADE,
+ help_text='Указывает на механизм, при помощи которого статья появилась на сайте.')
+
+ location = models.PositiveIntegerField(
+ 'Расположение статьи', choices=Location.choices, default=Location.INTERNAL,
+ help_text='Статью можно написать прямо на этом сайте, либо сформировать статью-ссылку на внешний ресурс.')
+
+ url = models.URLField(
+ 'URL статьи', null=True, blank=True, unique=True,
+ help_text='Внешний URL, по которому доступна статья, которой вы желаете поделиться.')
+
+ published_by_author = models.BooleanField('Я являюсь автором данной статьи', default=True)
+
+ nofollow = models.BooleanField('Без перехода по ссылкам.', default=False) # nofollow
+
+ history = HistoricalRecords()
+
+ class Meta:
+
+ verbose_name = 'Статья'
+ verbose_name_plural = 'Статьи'
+
+ class Fields:
+
+ description = {
+ 'verbose_name': 'Введение',
+ 'help_text': 'Пара-тройка предложений, описывающих, о чём пойдёт речь в статье.',
+ }
+ linked = {
+ 'verbose_name': 'Связанные статьи',
+ 'help_text': (
+ 'Выберите статьи, которые имеют отношение к данной. Так, например, можно объединить статьи цикла.',)
+ }
+
+ @property
+ def turbo_content(self) -> str:
+ return self.text
+
+ @property
+ def is_handmade(self) -> bool:
+ """Возвращат флаг, указывающий на то, что статья создана на этом сайте."""
+ return self.source == self.Source.HANDMADE
+
+ def save(self, *args, **kwargs):
+
+ # Для верного определения уникальности.
+ if not self.url:
+ self.url = None
+
+ super().save(*args, **kwargs)
+
+ def update_data_from_url(self, url: str):
+ """Обновляет данные статьи, собирая информация, доступную по указанному URL.
+
+ :param url:
+
+ """
+ result = scrape_page(url)
+
+ if not result:
+ raise RemoteSourceError('Не удалось получить данные статьи. Проверьте доступность указанного URL.')
+
+ self.title = result['title']
+ self.description = result['content_less']
+ self.text_src = result['content_more']
+ self.source = self.Source.SCRAPING
diff --git a/pythonz/apps/models/book.py b/pythonz/apps/models/book.py
new file mode 100644
index 00000000..9254e4c5
--- /dev/null
+++ b/pythonz/apps/models/book.py
@@ -0,0 +1,56 @@
+
+from django.db import models
+from etc.models import InheritedModel
+from simple_history.models import HistoricalRecords
+from sitecats.models import ModelWithCategory
+
+from ..generics.models import CommonEntityModel, ModelWithAuthorAndTranslator, RealmBaseModel
+from .discussion import ModelWithDiscussions
+from .partner import ModelWithPartnerLinks
+from .person import PersonsLinked
+from .shared import HINT_IMPERSONAL_REQUIRED
+
+
+class Book(
+ InheritedModel, RealmBaseModel, CommonEntityModel, ModelWithDiscussions, ModelWithCategory,
+ ModelWithAuthorAndTranslator, ModelWithPartnerLinks, PersonsLinked):
+ """Модель сущности `Книга`."""
+
+ COVER_UPLOAD_TO: str = 'books'
+
+ isbn = models.CharField('ISBN', max_length=20, unique=True, null=True, blank=True)
+
+ isbn_ebook = models.CharField('ISBN эл. книги', max_length=20, unique=True, null=True, blank=True)
+
+ authors = models.ManyToManyField('Person', verbose_name='Авторы', related_name='books', blank=True)
+
+ history = HistoricalRecords()
+
+ persons_fields: list[str] = ['authors']
+
+ class Meta:
+
+ verbose_name = 'Книга'
+ verbose_name_plural = 'Книги'
+
+ class Fields:
+
+ title = 'Название книги'
+
+ description = {
+ 'verbose_name': 'Аннотация',
+ 'help_text': f'Аннотация к книге, или другое краткое описание. {HINT_IMPERSONAL_REQUIRED}',
+ }
+
+ linked = {
+ 'verbose_name': 'Связанные книги',
+ 'help_text': (
+ 'Выберите книги, которые имеют отношение к данной. '
+ 'Например, для книги-перевода можно указать оригинал.',)
+ }
+
+ year = 'Год издания'
+
+ @property
+ def turbo_content(self) -> str:
+ return self.make_html(self.description)
diff --git a/pythonz/apps/models/category.py b/pythonz/apps/models/category.py
new file mode 100644
index 00000000..8da98ae0
--- /dev/null
+++ b/pythonz/apps/models/category.py
@@ -0,0 +1,34 @@
+from django.db.models import Q, QuerySet
+from django.urls import reverse
+from sitecats.models import Category as Category_
+
+
+class Category(Category_):
+ """Посредник для мимикрии под области."""
+
+ class Meta:
+
+ proxy = True
+
+ @classmethod
+ def find(cls, *search_terms: str) -> QuerySet:
+ """Ищет указанный текст в категориях. Возвращает QuerySet.
+
+ :param search_terms: Строки для поиска.
+
+ """
+ q = Q()
+
+ for term in search_terms:
+ if term:
+ q |= Q(title__icontains=term) | Q(note__icontains=term)
+
+ return Category.objects.filter(q).order_by('time_created')
+
+ @property
+ def description(self) -> str:
+ return self.note
+
+ def get_absolute_url(self, **kwargs) -> str:
+ # Сокращённая реализация из RealmBaseModel.
+ return reverse(self.realm.get_details_urlname(), args=[self.id])
diff --git a/pythonz/apps/models/community.py b/pythonz/apps/models/community.py
new file mode 100644
index 00000000..f1b31578
--- /dev/null
+++ b/pythonz/apps/models/community.py
@@ -0,0 +1,76 @@
+
+from django.db import models
+from etc.models import InheritedModel
+from simple_history.models import HistoricalRecords
+from sitecats.models import ModelWithCategory
+
+from ..generics.models import CommonEntityModel, ModelWithAuthorAndTranslator, ModelWithCompiledText, RealmBaseModel
+from .discussion import ModelWithDiscussions
+from .place import Place
+from .shared import HINT_IMPERSONAL_REQUIRED, UtmReady
+
+
+class Community(
+ UtmReady, InheritedModel, RealmBaseModel, CommonEntityModel, ModelWithDiscussions, ModelWithCategory,
+ ModelWithCompiledText):
+ """Модель сообществ. Формально объединяет некоторую группу людей."""
+
+ allow_edit_published: bool = True
+ paginator_defer: list[str] = ['url', 'text', 'text_src']
+
+ place = models.ForeignKey(
+ Place, verbose_name='Место', related_name='communities', null=True, blank=True,
+ help_text='Для географически локализованных сообществ можно указать место (страна, город, село). '
+ 'Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».',
+ on_delete=models.SET_NULL)
+
+ contacts = models.CharField(
+ 'Контактные лица', null=True, blank=True, max_length=255,
+ help_text=(
+ 'Контактные лица через запятую, представляющие сообщество, координаторы, основатели.'
+ f'{ModelWithAuthorAndTranslator._hint_userlink}'))
+
+ url = models.URLField('Страница в сети', null=True, blank=True)
+
+ history = HistoricalRecords()
+
+ class Meta:
+
+ verbose_name = 'Сообщество'
+ verbose_name_plural = 'Сообщества'
+
+ class Fields:
+
+ title = 'Название сообщества'
+
+ cover = 'Логотип'
+
+ description = {
+ 'verbose_name': 'Кратко',
+ 'help_text': (
+ 'Сжатая предварительная информация о сообществе (например, направление деятельности). '
+ f'{HINT_IMPERSONAL_REQUIRED}')
+ }
+
+ text_src = {
+ 'verbose_name': 'Описание, принципы работы, правила, контактная информация',
+ 'help_text': HINT_IMPERSONAL_REQUIRED,
+ }
+
+ linked = {
+ 'verbose_name': 'Связанные сообщества',
+ 'help_text': 'Выберите сообщества, имеющие отношение к данному.',
+ }
+
+ year = 'Год образования'
+
+ @property
+ def turbo_content(self) -> str:
+ return self.text
+
+ def save(self, *args, **kwargs):
+
+ if not self.pk:
+ self.mark_published()
+
+ super().save(*args, **kwargs)
diff --git a/pythonz/apps/models/discussion.py b/pythonz/apps/models/discussion.py
new file mode 100644
index 00000000..b61492b5
--- /dev/null
+++ b/pythonz/apps/models/discussion.py
@@ -0,0 +1,56 @@
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+from etc.models import InheritedModel
+from simple_history.models import HistoricalRecords
+from sitecats.models import ModelWithCategory
+
+from ..generics.models import CommonEntityModel, ModelWithCompiledText, RealmBaseModel
+from ..utils import truncate_chars
+
+
+class Discussion(InheritedModel, RealmBaseModel, CommonEntityModel, ModelWithCategory, ModelWithCompiledText):
+ """Модель обсуждений. Пользователи могут обсудить желаемые темы и привязать обсужедние к сущности на сайте.
+ Фактически - форум.
+
+ """
+ allow_edit_anybody: bool = False
+
+ object_id = models.PositiveIntegerField(verbose_name='ID объекта', db_index=True, null=True, blank=True)
+
+ content_type = models.ForeignKey(
+ ContentType, verbose_name='Тип содержимого', related_name='%(class)s_discussions', null=True, blank=True,
+ on_delete=models.SET_NULL)
+
+ linked_object = GenericForeignKey()
+
+ history = HistoricalRecords()
+
+ class Meta:
+
+ verbose_name = 'Обсуждение'
+ verbose_name_plural = 'Обсуждения'
+
+ class Fields:
+
+ text = 'Обсуждение'
+
+ def save(self, *args, **kwargs):
+
+ if not self.pk:
+ self.mark_published()
+
+ super().save(*args, **kwargs)
+
+ def get_description(self) -> str:
+ return truncate_chars(self.text, 360, html=True)
+
+
+class ModelWithDiscussions(models.Model):
+ """Класс-примесь к моделям сущностей, для который разрешено оставление мнений."""
+
+ discussions = GenericRelation(Discussion)
+
+ class Meta:
+
+ abstract = True
diff --git a/pythonz/apps/models/event.py b/pythonz/apps/models/event.py
new file mode 100644
index 00000000..bfd61510
--- /dev/null
+++ b/pythonz/apps/models/event.py
@@ -0,0 +1,204 @@
+from datetime import datetime, timedelta
+from enum import unique
+from typing import Optional
+
+from django.db import models
+from django.db.models import F, QuerySet
+from django.utils import timezone
+from django.utils.formats import date_format
+from etc.models import InheritedModel
+from simple_history.models import HistoricalRecords
+from sitecats.models import ModelWithCategory
+
+from ..generics.models import CommonEntityModel, ModelWithAuthorAndTranslator, ModelWithCompiledText
+from ..integration.base import RemoteSource
+from ..integration.events import EventSource
+from .discussion import ModelWithDiscussions
+from .place import WithPlace
+from .shared import HINT_IMPERSONAL_REQUIRED, UtmReady
+
+
+class Event(
+ UtmReady, InheritedModel, CommonEntityModel, ModelWithDiscussions, ModelWithCategory,
+ ModelWithCompiledText, WithPlace):
+ """Модель сущности `Событие`."""
+
+ allow_edit_published: bool = True
+ notify_on_publish: bool = False
+ paginator_defer: list[str] = ['contacts', 'text', 'text_src']
+ utm_on_main: bool = False
+
+ @unique
+ class Spec(models.IntegerChoices):
+
+ DEDICATED = 1, 'Только Python'
+ HAS_SECTION = 2, 'В основном Python'
+ HAS_SOME = 3, 'Есть секция/отделение про Python'
+ MOST = 4, 'Есть упоминания про Python'
+
+ @unique
+ class Type(models.IntegerChoices):
+
+ MEETING = 1, 'Встреча'
+ CONFERENCE = 2, 'Конференция'
+ LECTURE = 3, 'Лекция'
+ SPRINT = 4, 'Спринт'
+
+ source_group = EventSource
+
+ contacts = models.CharField(
+ 'Контактные лица', null=True, blank=True, max_length=255,
+ help_text=(
+ 'Контактные лица через запятую, координирующие/устраивающие событие.'
+ f'{ModelWithAuthorAndTranslator._hint_userlink}'))
+
+ specialization = models.PositiveIntegerField('Специализация', choices=Spec.choices, default=Spec.DEDICATED)
+
+ type = models.PositiveIntegerField('Тип', choices=Type.choices, default=Type.MEETING)
+
+ time_start = models.DateTimeField('Начало', null=True, blank=True)
+
+ time_finish = models.DateTimeField(
+ 'Завершение', null=True, blank=True,
+ help_text='Дату завершения можно и не указывать.')
+
+ fee = models.BooleanField('Участие платное', null=True, blank=True, db_index=True)
+
+ history = HistoricalRecords()
+
+ class Meta:
+
+ verbose_name = 'Событие'
+ verbose_name_plural = 'События'
+ unique_together = ('src_alias', 'src_id')
+
+ class Fields:
+
+ description = {
+ 'verbose_name': 'Краткое описание',
+ 'help_text': f'Краткое описание события. {HINT_IMPERSONAL_REQUIRED}',
+ }
+
+ text_src = {
+ 'verbose_name': 'Описание, контактная информация',
+ 'help_text': HINT_IMPERSONAL_REQUIRED,
+ }
+
+ place = {
+ 'help_text': (
+ 'Укажите место проведения мероприятия.Конкретный адрес следует указывать в описании. '
+ 'Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».'),
+ }
+
+ cover = 'Логотип'
+
+ def save(self, *args, **kwargs):
+
+ if not self.pk:
+ self.mark_published()
+
+ super().save(*args, **kwargs)
+
+ def get_display_type(self) -> str:
+ return self.Type(self.type).label
+
+ def get_display_specialization(self) -> str:
+ return self.Spec(self.specialization).label
+
+ @property
+ def page_title(self):
+
+ title = f'{self.get_display_type()} {self.title}'
+
+ if time_start := self.time_start:
+ title = f'{title} {date_format(time_start, "d E Y года")}'
+
+ return title
+
+ @property
+ def is_in_past(self) -> bool | None:
+
+ field = self.time_finish or self.time_start
+
+ if field is None:
+ return None
+
+ return field < timezone.now()
+
+ @property
+ def is_now(self) -> bool:
+
+ if not all([self.time_start, self.time_finish]):
+ return False
+
+ return self.time_start <= timezone.now() <= self.time_finish
+
+ @property
+ def time_forgetmenot(self) -> datetime:
+ """Дата напоминания о предстоящем событии (на сутки ранее начала события)."""
+ return self.time_start - timedelta(days=1)
+
+ @classmethod
+ def get_featured(cls, *, candidate: 'Event', dt_stale: datetime) -> Optional['Event']:
+ now = timezone.now()
+
+ featured = cls.objects.published().filter(
+ time_start__lte=now,
+ time_finish__gt=now,
+ ).first()
+
+ if featured is None:
+ return super().get_featured(candidate=candidate, dt_stale=dt_stale)
+
+ return featured
+
+ @classmethod
+ def spawn_object(cls, item_data: dict, *, source: RemoteSource):
+
+ big = item_data.pop('big')
+ page_info = item_data.pop('__page_info')
+
+ obj: Event = super().spawn_object(item_data, source=source)
+ obj.specialization = cls.Spec.HAS_SECTION
+
+ if big:
+ obj.type = cls.Type.CONFERENCE
+
+ if page_info:
+ site_name = page_info.site_name
+
+ if description := page_info.description:
+ if site_name == 'Meetup':
+ # Meetup в описание пихает мусорную конкатенацию.
+ description = page_info.title or ''
+
+ obj.description = description
+
+ images = page_info.images
+ if images:
+ image_src = images[0].get('src')
+ if image_src:
+ obj.update_cover_from_url(image_src)
+
+ return obj
+
+ @classmethod
+ def get_paginator_objects(cls) -> QuerySet:
+ now = timezone.now()
+
+ # Сначала грядущие в порядке приближения, потом прошедшие в порядке отдалённости.
+ qs = cls.objects.published().annotate(
+ past=models.Case(
+ models.When(time_start__gte=now, then=False),
+ models.When(time_start__lt=now, then=True),
+ output_field=models.BooleanField(),
+ )
+ ).annotate(
+ gap=models.Case(
+ models.When(time_start__gte=now, then=F('time_start') - now),
+ models.When(time_start__lt=now, then=now - F('time_start')),
+ output_field=models.DurationField(),
+ )
+ ).order_by('past', 'gap')
+
+ return qs
diff --git a/pythonz/apps/models/extrenal.py b/pythonz/apps/models/extrenal.py
new file mode 100644
index 00000000..86e06dbf
--- /dev/null
+++ b/pythonz/apps/models/extrenal.py
@@ -0,0 +1,75 @@
+from enum import unique
+from itertools import chain
+
+from django.db import IntegrityError, models
+
+from ..generics.models import RealmBaseModel
+from ..integration.resources import PyDigestResource
+from .shared import UtmReady
+
+
+class ExternalResource(UtmReady, RealmBaseModel):
+ """Внешние ресурсы. Представляют из себя ссылки на страницы вне сайта."""
+
+ @unique
+ class Resource(models.TextChoices):
+
+ PYDIGEST = 'pydigest', 'pythondigest.ru'
+
+ RESOURCE_MAP = {
+ Resource.PYDIGEST: PyDigestResource
+ }
+
+ src_alias = models.CharField('Идентификатор источника', max_length=20, choices=Resource.choices)
+
+ realm_name = models.CharField('Идентификатор области на pythonz', max_length=20)
+
+ url = models.URLField('Страница ресурса', unique=True)
+
+ title = models.CharField('Название', max_length=255)
+
+ description = models.TextField('Описание', blank=True, default='')
+
+ is_external: bool = True
+ """Признак внешнего ресурса.
+ Используется в тех случаях, когда внешние ресурсы идут
+ вперемежку со внутренними.
+
+ """
+
+ class Meta:
+
+ verbose_name = 'Внешний ресурс'
+ verbose_name_plural = 'Внешние ресурсы'
+ ordering = ('-time_created',)
+
+ @classmethod
+ def fetch_new(cls):
+ """Добывает данные из источников и складирует их."""
+
+ for resource_alias, resource_cls in cls.RESOURCE_MAP.items():
+ resource_alias = resource_alias.value
+
+ entries = resource_cls.fetch_entries()
+
+ if not entries:
+ return
+
+ added = []
+ existing = []
+
+ for entry_data in entries:
+ new_resource = cls(**entry_data)
+ new_resource.src_alias = resource_alias
+ new_resource.mark_published()
+
+ try:
+ new_resource.save()
+ added.append(new_resource.url)
+
+ except IntegrityError:
+ existing.append(new_resource.url)
+
+ if added:
+ # Оставляем только те записи, которые до сих пор выдаёт внешний ресурс.
+ cls.objects.filter(src_alias=resource_alias).exclude(url__in=chain(added, existing)).delete()
diff --git a/pythonz/apps/models/partner.py b/pythonz/apps/models/partner.py
new file mode 100644
index 00000000..9019d983
--- /dev/null
+++ b/pythonz/apps/models/partner.py
@@ -0,0 +1,86 @@
+
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+
+
+class PartnerLink(models.Model):
+ """Модель партнёрских ссылок. Ссылки могут быть привязаны к любым сущностям сайта.
+ Логику формирования и отображения ссылок предоставляют классы из модуля partners.
+
+ """
+ object_id = models.PositiveIntegerField(verbose_name='ID объекта', db_index=True)
+
+ content_type = models.ForeignKey(
+ ContentType, verbose_name='Тип содержимого', related_name='%(class)s_partner_links',
+ on_delete=models.CASCADE)
+
+ partner_alias = models.CharField('Идентфикатор класса партнёра', max_length=50, db_index=True)
+
+ url = models.URLField(
+ 'Базовая ссылка',
+ help_text='Ссылка на партнёрскую страницу без указания партнёрских данных (идентификатора).')
+
+ description = models.CharField('Описание', max_length=255, null=True, blank=True)
+
+ linked_object = GenericForeignKey()
+
+ class Meta:
+
+ verbose_name = 'Партнёрская ссылка'
+ verbose_name_plural = 'Партнёрские ссылки'
+
+ def __str__(self):
+ return f'Ссылка {self.id} для {self.content_type} {self.object_id}'
+
+
+class ModelWithPartnerLinks(models.Model):
+ """Класс-примесь к моделям сущностей, для который разрешено добавление партнёрских ссылок."""
+
+ partner_links = GenericRelation(PartnerLink)
+
+ class Meta:
+
+ abstract = True
+
+ @classmethod
+ def partner_links_enrich(cls, data: dict) -> list[PartnerLink]:
+ """Пополняет партнёрские ссылки по данным из указанного словаря.
+ Возвращает список добавленных новых ссылок.
+
+ :param data:
+
+ """
+ content_type = ContentType.objects.get_for_model(cls, for_concrete_model=False)
+
+ result = []
+
+ for title, links in data.items():
+
+ try:
+ source_obj = cls.objects.get(title=title)
+
+ except cls.DoesNotExist:
+ # Нет полного совпадения.
+ # Пробуем сориентироваться по ссылкам.
+ source_obj = PartnerLink.objects.filter(url__in=links).first()
+
+ if source_obj:
+ source_obj = source_obj.linked_object
+
+ else:
+ source_obj = cls.objects.create(title=title)
+
+ for url, partner_alias in links.items():
+
+ link, link_created = PartnerLink.objects.get_or_create(
+ content_type=content_type,
+ object_id=source_obj.id,
+ partner_alias=partner_alias,
+ url=url,
+ )
+
+ if link_created:
+ result.append(link)
+
+ return result
diff --git a/pythonz/apps/models/pep.py b/pythonz/apps/models/pep.py
new file mode 100644
index 00000000..22f0cd32
--- /dev/null
+++ b/pythonz/apps/models/pep.py
@@ -0,0 +1,174 @@
+from enum import unique
+
+from django.db import models
+from django.db.models import Q, QuerySet
+
+from ..generics.models import CommonEntityModel, RealmBaseModel
+from ..integration.peps import sync as sync_peps
+from .discussion import ModelWithDiscussions
+from .version import Version
+
+
+class PEP(RealmBaseModel, CommonEntityModel, ModelWithDiscussions):
+ """Предложения по улучшению Питона.
+
+ Заполняются автоматически из репозитория https://github.com/python/peps
+
+ """
+ TPL_URL_PYORG: str = 'https://www.python.org/dev/peps/pep-%s/'
+
+ @unique
+ class Status(models.IntegerChoices):
+
+ DRAFT = 1, 'Черновик'
+ ACTIVE = 2, 'Действует'
+ WITHDRAWN = 3, 'Отозвано [автором]'
+ DEFERRED = 4, 'Отложено'
+ REJECTED = 5, 'Отклонено'
+ ACCEPTED = 6, 'Утверждено (принято; возможно не реализовано)'
+ FINAL = 7, 'Финализировано (работа завершена; реализовано)'
+ SUPERSEDED = 8, 'Заменено (имеется более актуальное PEP)'
+ FOOL = 9, 'Розыгрыш на 1 апреля'
+
+ STATUS_MAP = {
+ Status.DRAFT: ('Черн.', ''),
+ Status.ACTIVE: ('Действ.', 'success'),
+ Status.WITHDRAWN: ('Отозв.', 'danger'),
+ Status.DEFERRED: ('Отл.', ''),
+ Status.REJECTED: ('Откл.', 'danger'),
+ Status.ACCEPTED: ('Утв.', 'info'),
+ Status.FINAL: ('Фин.', 'success'),
+ Status.SUPERSEDED: ('Зам.', 'warning'),
+ Status.FOOL: ('Апр.', ''),
+ }
+
+ STATUSES_FINAL = [
+ Status.WITHDRAWN,
+ Status.REJECTED,
+ Status.SUPERSEDED,
+ Status.ACTIVE,
+ Status.FOOL,
+ Status.FINAL
+ ]
+
+ @unique
+ class Type(models.IntegerChoices):
+
+ PROCESS = 1, 'Процесс'
+ STANDARD = 2, 'Стандарт'
+ INFO = 3, 'Информация'
+
+ slug_pick: bool = True
+ slug_auto: bool = True
+ items_per_page: int = 40
+ details_related: list[str] = []
+
+ # Далее отключаем общую логику работы с удалёнными.
+ is_published: bool = True
+ is_deleted: bool = False
+ is_draft: bool = False
+
+ # title - перевод заголовка на русский
+ # description - английский заголовок
+ # slug - номер предложения дополненный нулями до 4х знаков
+ # time_published - дата создания PEP
+
+ title = models.CharField('Название', max_length=255)
+
+ num = models.PositiveIntegerField('Номер')
+
+ status = models.PositiveIntegerField('Статус', choices=Status.choices, default=Status.DRAFT)
+
+ type = models.PositiveIntegerField('Тип', choices=Type.choices, default=Type.STANDARD)
+
+ authors = models.ManyToManyField('Person', verbose_name='Авторы', related_name='peps', blank=True)
+
+ versions = models.ManyToManyField(Version, verbose_name='Версии Питона', related_name='peps', blank=True)
+
+ requires = models.ManyToManyField(
+ 'self', verbose_name='Зависит от', symmetrical=False, related_name='used_by', blank=True)
+
+ # Следующие два поля кажутся взаимообратными, но пока это не доказано.
+ superseded = models.ManyToManyField(
+ 'self', verbose_name='Заменено на', symmetrical=False, related_name='supersedes', blank=True)
+
+ replaces = models.ManyToManyField(
+ 'self', verbose_name='Поглощает', symmetrical=False, related_name='replaced_by', blank=True)
+
+ class Meta:
+
+ verbose_name = 'PEP'
+ verbose_name_plural = 'PEP'
+
+ def __str__(self):
+ return f'PEP {self.num} — {self.title}'
+
+ @property
+ def turbo_content(self) -> str:
+ return self.title
+
+ @classmethod
+ def get_actual(cls) -> QuerySet:
+ return cls.objects.order_by('-time_published').all()
+
+ def get_link_to_pyorg(self) -> str:
+ # Получает ссылку на pep в python.org
+ return self.TPL_URL_PYORG % self.slug
+
+ def generate_slug(self) -> str:
+ # Дополняется нулями слева до четырёх знаков.
+ return str(self.num).zfill(4)
+
+ @classmethod
+ def get_paginator_objects(cls) -> QuerySet:
+ return cls.objects.order_by(cls.paginator_order)
+
+ def get_description(self) -> str:
+ # Русское наименование для показа в рассылке и подобном.
+ return self.title
+
+ def mark_published(self):
+ """Не использует общий механизм публикации."""
+
+ @classmethod
+ def sync_from_repository(cls):
+ """Синхронизирует данные в локальной БД с данными репозитория PEP."""
+ sync_peps()
+
+ @property
+ def bg_class(self) -> str:
+ return self.STATUS_MAP[self.Status(self.status)][1]
+
+ @property
+ def display_status(self) -> str:
+ return self.Status(self.status).label
+
+ @property
+ def display_status_letter(self) -> str:
+ return self.STATUS_MAP[self.Status(self.status)][0]
+
+ @property
+ def display_type(self) -> str:
+ return self.Type(self.type).label
+
+ @property
+ def display_type_letter(self) -> str:
+ return self.Type(self.type).label[0]
+
+ @classmethod
+ def find(cls, *search_terms: str) -> QuerySet:
+ """Ищет указанный текст в справочнике. Возвращает QuerySet.
+
+ :param search_terms: Строка для поиска.
+
+ """
+ q = Q()
+
+ for term in search_terms:
+
+ if not term:
+ continue
+
+ q |= Q(slug__icontains=term) | Q(title__icontains=term) | Q(description__icontains=term)
+
+ return cls.get_actual().filter(q)
diff --git a/pythonz/apps/models/person.py b/pythonz/apps/models/person.py
new file mode 100644
index 00000000..1806850c
--- /dev/null
+++ b/pythonz/apps/models/person.py
@@ -0,0 +1,258 @@
+from functools import partial
+
+from django.conf import settings
+from django.db import models
+from django.db.models import Q, QuerySet
+from etc.models import InheritedModel
+
+from ..generics.models import ModelWithCompiledText, RealmBaseModel
+from ..utils import PersonName, sync_many_to_many
+from .shared import UtmReady
+from .user import User
+
+
+class PersonsLinked(models.Model):
+ """Примесь для моделей, имеющих поля многие-ко-многим, ссылающиеся на Person."""
+
+ persons_fields: list[str] = []
+
+ class Meta:
+
+ abstract = True
+
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+ self.sync_persons_fields()
+
+ def sync_persons_fields(self, known_persons: dict[str, list['Person']] = None):
+
+ if not self.persons_fields:
+ return
+
+ if known_persons is None:
+ known_persons = Person.get_known_persons()
+
+ for field in self.persons_fields:
+ src_field = field.rstrip('s') # authors - > author
+ self._sync_persons(getattr(self, src_field), field, known_persons)
+
+ def _sync_persons(
+ self,
+ names_str: str,
+ persons_field: str,
+ known_persons: dict[str, list['Person']],
+ related_attr: str = 'name'
+ ):
+ names_list = []
+
+ for name in names_str.split(','):
+ # Убираем разметку типа [u:1:идле]
+ name = name.strip(' []').rpartition(':')[2]
+ name and names_list.append(name)
+
+ sync_many_to_many(
+ names_list, self, persons_field, related_attr, known_persons,
+ unknown_handler=partial(self.create_person, publish=False))
+
+ @classmethod
+ def create_person(
+ cls,
+ person_name: str,
+ known_persons: dict[str, list['Person']],
+ *,
+ publish: bool = True
+ ) -> 'Person':
+ """Создаёт персону и добавляет её в словарь известных персон.
+
+ :param person_name:
+ :param known_persons:
+ :param publish:
+
+ """
+ person = Person.create(person_name, save=True, publish=publish)
+ Person.contribute_to_known_persons(person, known_persons)
+ return person
+
+
+class Person(UtmReady, InheritedModel, RealmBaseModel, ModelWithCompiledText):
+ """Модель сущности `Персона`.
+
+ Персона не обязана являться пользователем сайта, но между этими сущностями может быть связь.
+
+ """
+ details_related: list[str] = ['submitter', 'last_editor', 'user']
+ paginator_related: list[str] = []
+ paginator_order: str = 'name'
+ items_per_page: int = 1000
+
+ user = models.OneToOneField(
+ User, verbose_name='Пользователь', related_name='person', null=True, blank=True,
+ on_delete=models.SET_NULL)
+
+ name = models.CharField('Имя', max_length=90, blank=True)
+
+ name_en = models.CharField('Имя англ.', max_length=90, blank=True)
+
+ aka = models.CharField('Другие имена', max_length=255, blank=True) # Разделены ;
+
+ class Meta:
+
+ verbose_name = 'Персона'
+ verbose_name_plural = 'Персоны'
+
+ class Fields:
+
+ text = {'verbose_name': 'Описание'}
+ text_src = {'verbose_name': 'Описание (исх.)'}
+
+ def __str__(self):
+ return self.name
+
+ @property
+ def title(self) -> str:
+ return self.get_display_name()
+
+ @classmethod
+ def get_known_persons(cls) -> dict[str, list['Person']]:
+ """Возвращает словарь, индексированный именами персон.
+
+ Где значения являются списками с объектами моделей персон.
+ Если в списке больше одной модели, значит этому имени соответствует
+ несколько разных моделей персон.
+
+ """
+ known = {}
+
+ for person in cls.objects.exclude(status=cls.Status.DELETED):
+ cls.contribute_to_known_persons(person, known_persons=known)
+
+ return known
+
+ @classmethod
+ def contribute_to_known_persons(cls, person: 'Person', known_persons: dict[str, list['Person']]):
+ """Добавляет объект указанной персоны в словарь с известными персонами.
+
+ :param person:
+ :param known_persons: Объект изменяется в ходе выполнения метода.
+
+ """
+ def add_name(name: str):
+ """Заносит имя в разных вариантах в реестр известных имён.
+
+ :param name:
+
+ """
+ name = PersonName(name)
+
+ for variant in name.get_variants:
+
+ persons_for_variant = known_persons.setdefault(variant, [])
+
+ if person not in persons_for_variant: # Дубли не нужны.
+ persons_for_variant.append(person)
+
+ add_name(person.name)
+ add_name(person.name_en)
+
+ for aka_chunk in person.aka.split(';'):
+ add_name(aka_chunk)
+
+ @classmethod
+ def find(cls, *search_terms: str) -> QuerySet:
+ """Ищет персону по указанному имени. Возвращает QuerySet.
+
+ :param search_terms: Строка для поиска.
+
+ """
+ q = Q()
+
+ for term in search_terms:
+
+ if not term:
+ continue
+
+ q |= Q(name__icontains=term) | Q(name_en__icontains=term) | Q(aka__icontains=term)
+
+ return cls.get_actual().filter(q)
+
+ @classmethod
+ def create(cls, name: str, *, save: bool = False, publish: bool = True) -> 'Person':
+ """Создаёт объект персоны по имени.
+
+ :param name:
+ :param save: Следует ли сохранить объект в БД.
+ :param publish: Следует ли пометить объект опубликованным.
+
+ """
+ person = cls(
+ name=name,
+ name_en=name,
+ status=cls.Status.PUBLISHED if publish else cls.Status.DRAFT,
+ text_src='Описание отсутствует',
+ submitter_id=settings.ROBOT_USER_ID,
+ )
+
+ if save:
+ person.save(notify_published=False, notify_new=False)
+
+ return person
+
+ @classmethod
+ def get_paginator_objects(cls) -> list:
+ persons = super().get_paginator_objects()
+
+ def sort_by_surname(person):
+ split = person.name.rsplit(' ', 1)
+ name = ' '.join(reversed(split))
+ person.name = name
+ return name
+
+ result = sorted(persons, key=sort_by_surname)
+
+ return result
+
+ def get_materials(self) -> dict:
+ """Возвращает словарь с матералами, созданными персоной.
+
+ Индексирован названиями разделов сайта; значения — список моделей материалов.
+
+ """
+ from ..realms import get_realm # noqa: PLC0415
+
+ realms = [
+ get_realm('pep'),
+ get_realm('book'),
+ get_realm('video'),
+ get_realm('app'),
+ ] # Пока ограничимся.
+
+ downloads: dict[str, dict] = {}
+ materials: dict[str, tuple[str, QuerySet]] = {}
+
+ materials_data = {
+ 'downloads_map': downloads,
+ 'items': materials,
+ }
+
+ for realm in realms:
+
+ realm_model = realm.model
+ realm_name = realm_model.get_verbose_name_plural()
+
+ _, plural = realm.get_names()
+
+ items = getattr(self, plural)
+
+ if realm.name != 'pep':
+ items = items.published()
+
+ items = items.order_by('slug', 'title')
+
+ if items:
+ materials[realm_name] = (plural, items)
+
+ if realm.name == 'app':
+ for item in items:
+ downloads[item.slug] = item.downloads
+
+ return materials_data
diff --git a/pythonz/apps/models/place.py b/pythonz/apps/models/place.py
new file mode 100644
index 00000000..7df64830
--- /dev/null
+++ b/pythonz/apps/models/place.py
@@ -0,0 +1,136 @@
+from enum import unique
+from typing import Optional
+
+from django.db import IntegrityError, models
+from simple_history.models import HistoricalRecords
+
+from ..generics.models import RealmBaseModel, WithRemoteSource
+from ..integration.base import RemoteSource
+from ..integration.utils import get_location_data
+from .discussion import ModelWithDiscussions
+
+
+class Place(RealmBaseModel, ModelWithDiscussions):
+ """Географическое место. Для людей, событий и пр."""
+
+ details_related: list[str] = ['last_editor']
+
+ @unique
+ class GeoType(models.TextChoices):
+
+ COUNTRY = 'country', 'Страна'
+ LOCALITY = 'locality', 'Местность'
+ HOUSE = 'house', 'Здание'
+
+ title = models.CharField('Название', max_length=255)
+
+ description = models.TextField('Описание', blank=True, null=False, default='')
+
+ geo_title = models.TextField('Полное название', null=True, blank=True, unique=True)
+
+ geo_bounds = models.CharField('Пределы', max_length=255, null=True, blank=True)
+
+ geo_pos = models.CharField('Координаты', max_length=255, null=True, blank=True)
+
+ geo_type = models.CharField(
+ 'Тип', max_length=25, null=True, blank=True, choices=GeoType.choices, db_index=True)
+
+ history = HistoricalRecords()
+
+ class Meta:
+
+ verbose_name = 'Место'
+ verbose_name_plural = 'Места'
+
+ def __str__(self):
+ return self.geo_title or self.title
+
+ @property
+ def turbo_content(self) -> str:
+ return self.make_html(self.description)
+
+ def get_pos(self) -> tuple[str, str]:
+ """Возвращает координаты объекта в виде кортежа: (широта, долгота)."""
+ lat, lng = self.geo_pos.split('|')
+ return lat, lng
+
+ def get_description(self) -> str:
+ return self.description
+
+ @classmethod
+ def create_place_from_name(cls, name: str) -> Optional['Place']:
+ """Создаёт место по его имени.
+
+ :param name:
+
+ """
+ loc_data = get_location_data(name)
+
+ if not loc_data:
+ return None
+
+ full_title = loc_data['name']
+
+ place = cls(
+ title=loc_data['requested_name'],
+ geo_title=full_title,
+ geo_bounds=loc_data['bounds'],
+ geo_pos=loc_data['pos'],
+ geo_type=loc_data['type']
+ )
+
+ try:
+ place.save()
+
+ except IntegrityError:
+ place = cls.objects.get(geo_title=full_title)
+
+ return place
+
+
+class WithPlace(WithRemoteSource):
+ """Примесь для сущностей, которые могут связывать с местом."""
+
+ src_place_name = models.CharField('Название места в источнике', max_length=255, default='')
+
+ src_place_id = models.CharField('ID места в источнике', max_length=50, db_index=True, default='')
+
+ place = models.ForeignKey(
+ Place, verbose_name='Место', related_name='lnk_%(class)s', null=True, blank=True,
+ on_delete=models.SET_NULL)
+
+ class Meta:
+
+ abstract = True
+
+ @classmethod
+ def spawn_object(cls, item_data: dict, *, source: RemoteSource):
+ obj = super().spawn_object(item_data, source=source)
+ obj.link_to_place()
+ return obj
+
+ def link_to_place(self):
+ """Связывает запись с местом Place, заполняя атрибут place_id."""
+
+ src_place_id = self.src_place_id
+
+ if not src_place_id:
+ # Нам необходим некий идентификатор места во внешней системе.
+ # Без него ничего делать не будем.
+ return
+
+ # Попробуем найти ранее связанные записи, чтобы не нагружать API карт.
+ match = self.__class__.objects.filter(
+ src_alias=self.src_alias,
+ src_place_id=src_place_id,
+ ).first()
+
+ if match:
+ self.place_id = match.place_id
+
+ else:
+ # Вычисляем место.
+ match = Place.create_place_from_name(self.src_place_name)
+
+ if match:
+ self.place_id = match.id
diff --git a/pythonz/apps/models/reference.py b/pythonz/apps/models/reference.py
new file mode 100644
index 00000000..059b36f1
--- /dev/null
+++ b/pythonz/apps/models/reference.py
@@ -0,0 +1,221 @@
+from enum import unique
+
+from django.db import models
+from django.db.models import Q, QuerySet
+from django.utils.functional import cached_property
+from etc.models import InheritedModel
+from simple_history.models import HistoricalRecords
+
+from ..generics.models import CommonEntityModel, ModelWithCompiledText, RealmBaseModel
+from .discussion import ModelWithDiscussions
+from .version import Version
+
+
+class ReferenceMissing(models.Model):
+ """Промахи при поиске в справочнике."""
+
+ term = models.CharField('Термин', max_length=255, unique=True)
+
+ synonyms = models.TextField('Синонимы', blank=True)
+
+ hits = models.PositiveIntegerField('Запросы', default=0)
+
+ class Meta:
+
+ verbose_name = 'Промах справочника'
+ verbose_name_plural = 'Промахи справочника'
+
+ def __str__(self):
+ return self.term
+
+ @classmethod
+ def add(cls, search_term: str) -> bool:
+ """Добавляет данные по указанному термину в реестр промахов.
+ Возвращает True, если была добавлена новая запись.
+
+ :param search_term: Термин для поиска.
+
+ """
+ obj = cls.objects.filter(
+ Q(term__icontains=search_term) |
+ Q(synonyms__icontains=search_term)
+
+ ).first()
+
+ if obj:
+ obj.hits += 1
+ obj.save()
+
+ else:
+ cls(term=search_term, hits=1).save()
+
+ return obj is None
+
+
+class Reference(InheritedModel, RealmBaseModel, CommonEntityModel, ModelWithDiscussions, ModelWithCompiledText):
+ """Модель сущности `Справочник`."""
+
+ slug_pick: bool = True
+ slug_auto: bool = True
+ allow_linked: bool = False
+ allow_edit_published: bool = True
+ details_related: list[str] = ['parent', 'submitter']
+
+ @unique
+ class Type(models.IntegerChoices):
+
+ CHAPTER = 1, 'Раздел справки'
+ PACKAGE = 2, 'Описание пакета'
+ MODULE = 3, 'Описание модуля'
+ FUNCTION = 4, 'Описание функции'
+ CLASS = 5, 'Описание класса/типа'
+ METHOD = 6, 'Описание метода класса/типа'
+ PROPERTY = 7, 'Описание свойства класса/типа'
+
+ TYPES_CALLABLE = {
+ Type.METHOD,
+ Type.FUNCTION,
+ Type.CLASS,
+ }
+
+ TYPES_BUNDLE = {
+ Type.CHAPTER,
+ Type.PACKAGE,
+ Type.MODULE,
+ }
+
+ type = models.PositiveIntegerField(
+ 'Тип статьи', choices=Type.choices, default=Type.CHAPTER,
+ help_text='Служит для структурирования информации. Справочные статьи разных типов могут выглядеть по-разному.')
+
+ parent = models.ForeignKey(
+ 'self', related_name='children', verbose_name='Родитель', db_index=True, null=True, blank=True,
+ help_text='Укажите родительский раздел. '
+ 'Например, для модуля можно указать раздел справки, в которому он относится; '
+ 'для метода — класс.',
+ on_delete=models.PROTECT)
+
+ version_added = models.ForeignKey(
+ Version, related_name='%(class)s_added', verbose_name='Добавлено в', null=True, blank=True,
+ help_text='Версия Python, для которой впервые стала актульна данная статья '
+ '(версия, где впервые появился модуль, пакет, класс, функция).',
+ on_delete=models.SET_NULL)
+
+ version_deprecated = models.ForeignKey(
+ Version, related_name='%(class)s_deprecated', verbose_name='Устарело в', null=True, blank=True,
+ help_text='Версия Python, для которой впервые данная статья перестала быть актуальной '
+ '(версия, где модуль, пакет, класс, функция были объявлены устаревшими).',
+ on_delete=models.SET_NULL)
+
+ func_proto = models.CharField(
+ 'Прототип', max_length=250, null=True, blank=True,
+ help_text='Для функций/методов. Описание интерфейса, например: my_func(arg, kwarg=None) ')
+
+ func_params = models.TextField(
+ 'Параметры', null=True, blank=True,
+ help_text='Для функций/методов. Описание параметров функции.')
+
+ func_result = models.CharField(
+ 'Результат', max_length=250, null=True, blank=True,
+ help_text='Для функций/методов. Описание результата, например: int .')
+
+ pep = models.PositiveIntegerField(
+ 'PEP', null=True, blank=True,
+ help_text='Номер предложения по улучшению Питона, связанного с этой статьёй, например: 8 для PEP-8')
+
+ search_terms = models.CharField(
+ 'Термины поиска', max_length=500, blank=True, default='',
+ help_text='Дополнительные фразы, по которым можно найти данную статью, например: «список», для «list» ')
+
+ history = HistoricalRecords()
+
+ class Meta:
+
+ verbose_name = 'Статья справочника'
+ verbose_name_plural = 'Справочник'
+ ordering = ('parent_id', 'title')
+
+ class Fields:
+
+ title = {
+ 'verbose_name': 'Название',
+ 'help_text': ('Здесь следует указать название раздела справки '
+ 'или пакета, модуля, класса, метода, функции и т.п.')
+ }
+ description = {
+ 'verbose_name': 'Кратко',
+ 'help_text': 'Краткое описание для раздела или пакета, модуля, класса, метода, функции и т.п.',
+ }
+ text_src = {
+ 'verbose_name': 'Описание',
+ 'help_text': 'Подробное описание. Здесь же следует располагать примеры кода.',
+ }
+
+ @cached_property
+ def page_keywords(self):
+ title = self.title
+ title_chunked = title.replace('.', ', ')
+
+ if title != title_chunked:
+ title = f"{title}, {title_chunked}"
+
+ return f'{title}, {self.search_terms}'.rstrip(', ')
+
+ @property
+ def turbo_content(self) -> str:
+ return self.description
+
+ @property
+ def is_type_callable(self) -> bool:
+ return self.type in self.TYPES_CALLABLE
+
+ @property
+ def is_type_bundle(self) -> bool:
+ return self.type in self.TYPES_BUNDLE
+
+ @property
+ def is_type_method(self) -> bool:
+ return self.type == self.Type.METHOD
+
+ @property
+ def is_type_module(self) -> bool:
+ return self.type == self.Type.MODULE
+
+ @property
+ def is_type_class(self) -> bool:
+ return self.type == self.Type.CLASS
+
+ @property
+ def is_type_chapter(self) -> bool:
+ return self.type == self.Type.CHAPTER
+
+ @classmethod
+ def get_actual(cls, parent: 'Reference' = None, exclude_id: int = None) -> QuerySet:
+
+ qs = cls.objects.published()
+
+ if parent is not None:
+ qs = qs.filter(parent=parent)
+
+ if exclude_id is not None:
+ qs = qs.exclude(pk=exclude_id)
+
+ return qs.order_by('-time_published').all()
+
+ @classmethod
+ def find(cls, *search_terms: str) -> QuerySet:
+ """Ищет указанный текст в справочнике. Возвращает QuerySet.
+
+ :param search_terms: Строка для поиска.
+
+ """
+ q = Q()
+
+ for term in search_terms:
+
+ if not term:
+ continue
+
+ q |= Q(title__icontains=term) | Q(search_terms__icontains=term)
+
+ return cls.objects.published().filter(q).order_by('time_published')
diff --git a/pythonz/apps/models/shared.py b/pythonz/apps/models/shared.py
new file mode 100644
index 00000000..b3569655
--- /dev/null
+++ b/pythonz/apps/models/shared.py
@@ -0,0 +1,17 @@
+from ..utils import UTM
+
+HINT_IMPERSONAL_REQUIRED = (
+ 'Без обозначения личного отношения. Личное отношение можно выразить в Обсуждениях к материалу. ')
+
+
+class UtmReady:
+ """Примесь, добавляющая модели метод для получения URL с метками UTM."""
+
+ url_attr: str = 'url'
+
+ utm_on_main: bool = True
+ """Использовать utm URLы в карточках на главной."""
+
+ def get_utm_url(self) -> str:
+ """Возвращает URL с UTM-метками."""
+ return UTM.add_to_external_url(getattr(self, self.url_attr) or '')
diff --git a/pythonz/apps/models/summary.py b/pythonz/apps/models/summary.py
new file mode 100644
index 00000000..9c59bf79
--- /dev/null
+++ b/pythonz/apps/models/summary.py
@@ -0,0 +1,149 @@
+import json
+from typing import TYPE_CHECKING
+
+from django.conf import settings
+from django.db import models
+
+from ..generics.models import RealmBaseModel
+from ..integration.summary import SUMMARY_FETCHERS
+from ..utils import get_datetime_from_till
+from .article import Article
+from .category import Category
+from .user import User
+
+if TYPE_CHECKING:
+ from ..integration.summary.base import ItemsFetcherBase, SummaryItem
+
+
+TypeFetched = dict[str, list['SummaryItem']]
+
+
+class Summary(RealmBaseModel):
+ """Cводки. Ссылки на материалы, собранные с внешних ресурсов."""
+
+ data_items = models.TextField('Текущие элементы сводки')
+
+ data_result = models.TextField(
+ 'Результат для фильтрации сводки',
+ help_text='Данные для фильтрации элементов сводки при последующих обращениях к ресурсу.')
+
+ class Meta:
+
+ verbose_name = 'Сводка'
+ verbose_name_plural = 'Сводки'
+ ordering = ('-time_created',)
+
+ def __str__(self):
+ return str(self.time_created)
+
+ @classmethod
+ def make_text(cls, fetched: TypeFetched) -> str:
+ """Компонует текст из полученных извне данных.
+
+ :param fetched:
+
+ """
+ summary_text = []
+
+ trans_map = str.maketrans(dict.fromkeys(('|', '`', '\t', '\n', '\r')))
+
+ for fetcher_alias, items in fetched.items():
+
+ if not items:
+ continue
+
+ summary_text.append(f'.. title:: {SUMMARY_FETCHERS[fetcher_alias].title}')
+ summary_text.append('.. table::')
+
+ for item in items:
+ title = item.title.translate(trans_map)
+
+ line = f'`{title}<{item.url}>`_'
+
+ if description := item.description:
+ description = description.translate(trans_map)
+ line += f' — {description}'
+
+ summary_text.append(line)
+
+ summary_text.append('\n')
+
+ if not summary_text:
+ return ''
+
+ summary_text.append('')
+ summary_text = '\n'.join(summary_text)
+
+ return summary_text
+
+ @classmethod
+ def create_article(cls, fetched: TypeFetched | None = None) -> 'Article':
+ """Создаёт сводку, используя данные, полученные извне.
+
+ :param fetched: Данные для составления статьи.
+
+ """
+ if fetched is None:
+ fetched = cls.fetch()
+
+ summary_text = cls.make_text(fetched)
+
+ format_date = lambda d: d.date().strftime('%d.%m.%Y') # noqa: E731
+ date_from, date_till = get_datetime_from_till(7)
+
+ robot_id = settings.ROBOT_USER_ID
+
+ article = Article(
+ title=f'Сводка {format_date(date_from)} — {format_date(date_till)}',
+ description='А теперь о том, что происходило в последнее время на других ресурсах.',
+ submitter_id=robot_id,
+ text_src=summary_text,
+ source=Article.Source.SCRAPING,
+ published_by_author=False,
+ nofollow=True,
+ )
+ article.mark_published()
+ article.save()
+
+ article.add_to_category(Category(pk=settings.SUMMARY_CATEGORY_ID), User(pk=robot_id))
+
+ return article
+
+ @classmethod
+ def fetch(cls) -> TypeFetched:
+ """Добывает данные из источников, складирует их и возвращает в виде словаря."""
+ latest = cls.objects.order_by('-pk').first()
+
+ prev_results = json.loads(getattr(latest, 'data_result', '{}'))
+ prev_dt = getattr(latest, 'time_created', None)
+
+ all_items = {}
+ all_results = {}
+
+ for fetcher_alias, fetcher_cls in SUMMARY_FETCHERS.items():
+
+ prev_result = prev_results.get(fetcher_alias) or []
+
+ fetcher: ItemsFetcherBase = fetcher_cls(
+ previous_result=prev_result,
+ previous_dt=prev_dt
+ )
+ result = fetcher.run()
+
+ if result is None:
+ # По всей видимости, произошла необработанная ошибка.
+ items, result = [], prev_result
+
+ else:
+ items, result = result
+
+ all_items[fetcher_alias] = items
+ all_results[fetcher_alias] = result
+
+ new_summary = cls(
+ data_items=json.dumps(all_items),
+ data_result=json.dumps(all_results)
+ )
+ new_summary.save()
+
+ return all_items
diff --git a/pythonz/apps/models/user.py b/pythonz/apps/models/user.py
new file mode 100644
index 00000000..d95d6a03
--- /dev/null
+++ b/pythonz/apps/models/user.py
@@ -0,0 +1,184 @@
+
+from django.contrib.auth.models import AbstractUser, UserManager
+from django.core.exceptions import FieldError
+from django.db import models
+from django.db.models import QuerySet
+from siteflags.utils import get_flag_model
+
+from ..generics.models import RealmBaseModel
+from ..integration.utils import get_timezone_name
+from .category import Category
+from .place import Place
+from .shared import UtmReady
+
+
+class User(UtmReady, RealmBaseModel, AbstractUser):
+ """Наша модель пользователей."""
+
+ allow_edit_anybody: bool = False
+ items_per_page: int = 14
+ details_related: list[str] = ['last_editor', 'person', 'place']
+
+ objects = UserManager()
+
+ place = models.ForeignKey(
+ Place, verbose_name='Место', related_name='users', null=True, blank=True,
+ help_text='Место вашего пребывания (страна, город, село). '
+ 'Например: «Россия, Новосибирск» или «Новосибирск», но не «Нск».',
+ on_delete=models.SET_NULL)
+
+ profile_public = models.BooleanField(
+ 'Публичный профиль', default=False, db_index=True,
+ help_text='Если выключить, то увидеть ваш профиль сможете только вы. '
+ 'В списках пользователей профиль значиться тоже не будет.')
+
+ timezone = models.CharField(
+ 'Часовой пояс', max_length=150, null=True, blank=True,
+ help_text='Название часового пояса. Например: Asia/Novosibirsk. '
+ '* Устанавливается автоматически в зависимости от места пребывания (см. выше).')
+
+ email_public = models.EmailField(
+ 'Эл. почта', null=True, blank=True,
+ help_text='Адрес электронной почты для показа посетителям сайта.')
+
+ url = models.URLField('Страница в сети', null=True, blank=True)
+
+ karma = models.DecimalField('Карма', max_digits=6, decimal_places=2, default=0)
+
+ class Meta:
+
+ verbose_name = 'Пользователь'
+ verbose_name_plural = 'Пользователи'
+
+ def __str__(self):
+ return self.get_full_name() or self.get_username_partial()
+
+ @property
+ def title(self) -> str:
+ return self.get_display_name()
+
+ @property
+ def is_draft(self) -> bool:
+ # Не считаем черновиком, считаем опубликованным.
+ return False
+
+ def set_timezone_from_place(self):
+ """Устанавливает временную зону, исходя из места расположения."""
+
+ if self.place is None:
+ self.timezone = None
+ return True
+
+ if geo_pos := self.place.geo_pos:
+ lat, lng = geo_pos.split(',')
+ self.timezone = get_timezone_name(lat, lng)
+
+ def get_drafts(self) -> dict[str, QuerySet]:
+ """Возвращает словарь с неопубликованными материалами пользователя.
+ Индексирован названиями разделов сайта; значения — списки материалов.
+
+ """
+ from ..realms import get_realms_models # noqa: PLC0415
+
+ drafts = {}
+
+ for realm_model in get_realms_models():
+
+ try:
+ realm_name = realm_model.get_verbose_name_plural()
+
+ except AttributeError:
+ pass
+
+ else:
+ items = realm_model.objects.filter(
+ status__in=(self.Status.DRAFT, self.Status.POSTPONED),
+ submitter_id=self.id
+
+ ).order_by('time_created')
+
+ if items:
+ drafts[realm_name] = items
+
+ return drafts
+
+ def get_stats(self) -> dict[str, dict]:
+ """Возвращает словарь со статистикой пользователя.
+
+ Индексирован названиями разделов сайта; значения — словарь со статистикой:
+ cnt_published - кол-во опубликованных материалов
+ cnt_postponed - кол-во материалов, назначенных к отложенной публикации
+
+ """
+ from ..realms import get_realms_models # noqa: PLC0415
+
+ stats = {}
+ for realm_model in get_realms_models():
+
+ try:
+ realm_name = realm_model.get_verbose_name_plural()
+ cnt_published = realm_model.objects.published().filter(submitter_id=self.id).count()
+ cnt_postponed = realm_model.objects.postponed().filter(submitter_id=self.id).count()
+
+ except (FieldError, AttributeError):
+ pass
+
+ else:
+
+ if cnt_published or cnt_postponed:
+ stats[realm_name] = {
+ 'cnt_published': cnt_published,
+ 'cnt_postponed': cnt_postponed,
+ }
+
+ return stats
+
+ def get_bookmarks(self) -> dict[str, QuerySet]:
+ """Возвращает словарь с избранными пользователем элементами (закладками).
+ Словарь индексирован классами моделей различных сущностей, в значениях - списки с самими сущностями.
+
+ """
+ from ..realms import get_realms_models # noqa: PLC0415
+
+ FLAG_MODEL = get_flag_model()
+
+ bookmarks = FLAG_MODEL.get_flags_for_types(
+ get_realms_models(), user=self, status=RealmBaseModel.FLAG_STATUS_BOOKMARK,
+ allow_empty=False
+ )
+
+ for realm_model, flags in bookmarks.items():
+ ids = [flag.object_id for flag in flags]
+ items = realm_model.objects.filter(id__in=ids)
+ bookmarks[realm_model] = items
+
+ return bookmarks
+
+ @property
+ def is_deleted(self) -> bool:
+ return not self.is_active
+
+ @classmethod
+ def get_actual(cls) -> QuerySet:
+ return cls.objects.filter(is_active=True, profile_public=True).order_by('-date_joined').all()
+
+ @classmethod
+ def get_paginator_objects(cls) -> QuerySet:
+ return cls.get_actual()
+
+ @classmethod
+ def get_most_voted_objects(cls, category: Category = None, base_query: QuerySet = None) -> QuerySet:
+ query = cls.objects.filter(supporters_num__gt=0)
+ query = query.select_related('submitter').order_by('-supporters_num')
+ return query.all()[:5]
+
+ def get_username_partial(self) -> str:
+ return self.username.split('@')[0]
+
+ def get_description(self) -> str:
+ """Возвращает вычисляемое описание объекта.
+ Обычно должен использоваться вместо обращения к атрибуту description,
+ которого может не сущестовать у модели.
+
+ """
+ return self.get_display_name()
diff --git a/pythonz/apps/models/vacancy.py b/pythonz/apps/models/vacancy.py
new file mode 100644
index 00000000..b1e889b4
--- /dev/null
+++ b/pythonz/apps/models/vacancy.py
@@ -0,0 +1,188 @@
+from datetime import timedelta
+from statistics import median
+
+from django.db import models
+from django.db.models import Count
+from django.utils.timezone import now
+
+from ..integration.vacancies import VacancySource
+from ..utils import format_currency
+from .place import Place, WithPlace
+from .shared import UtmReady
+
+
+class Vacancy(UtmReady, WithPlace):
+
+ paginator_related: list[str] = ['place']
+ items_per_page: int = 15
+ notify_on_publish: bool = False
+
+ source_group = VacancySource
+
+ title = models.CharField('Название', max_length=255)
+
+ url_api = models.URLField('URL API', null=True, blank=True)
+
+ url_logo = models.URLField('URL логотипа', null=True, blank=True)
+
+ employer_name = models.CharField('Работодатель', max_length=255)
+
+ salary_from = models.PositiveIntegerField('Заработная плата', null=True, blank=True)
+
+ salary_till = models.PositiveIntegerField('З/п до', null=True, blank=True)
+
+ salary_currency = models.CharField('Валюта', max_length=255, null=True, blank=True)
+
+ class Meta:
+
+ verbose_name = 'Вакансия'
+ verbose_name_plural = 'Работа'
+ unique_together = ('src_alias', 'src_id')
+
+ @property
+ def cover(self) -> str:
+ return self.url_logo
+
+ @property
+ def description(self) -> str:
+ # todo Убрать после перевода всего на get_description.
+ return self.get_description()
+
+ def get_description(self) -> str:
+ """Возвращает вычисляемое описание объекта.
+ Обычно должен использоваться вместо обращения к атрибуту description,
+ которого может не сущестовать у модели.
+
+ """
+ chunks = [self.employer_name, self.src_place_name]
+ salary_chunk = self.get_salary_str()
+
+ if salary_chunk:
+ chunks.append(salary_chunk)
+
+ return ', '.join(chunks)
+
+ @classmethod
+ def get_places_stats(cls, min_count: int = 5) -> list[Place]:
+ """Возвращает статистику по количеству вакансий на местах.
+
+ :param min_count: Минимальное количество вакансий для попадения
+ места в результат.
+
+ """
+ stats = list(Place.objects.filter(
+ id__in=cls.objects.published().filter(place__isnull=False).distinct().values_list('place_id', flat=True),
+ lnk_vacancy__status=cls.Status.PUBLISHED
+
+ ).annotate(vacancies_count=Count('lnk_vacancy')).filter(
+ vacancies_count__gte=min_count
+
+ ).order_by('-vacancies_count', 'title'))
+
+ return stats
+
+ @classmethod
+ def get_salary_stats(cls, place: Place | None = None) -> dict:
+ """Возвращает статистику по зарплатам.
+
+ :param place: Место, для которого следует получить статистику.
+
+ """
+ filter_kwargs = {
+ 'salary_currency__isnull': False,
+ 'salary_till__isnull': False,
+ 'salary_from__gt': 900,
+ 'status': cls.Status.PUBLISHED,
+ }
+
+ if place is not None:
+ filter_kwargs['place'] = place
+
+ stats = list(cls.objects.published().filter(
+ **filter_kwargs
+
+ ).values(
+ 'salary_currency',
+ 'salary_from',
+ 'salary_till',
+ ))
+
+ by_currency = {}
+
+ for stat_row in stats:
+
+ row = by_currency.setdefault(stat_row['salary_currency'], {
+ 'min': float('inf'),
+ 'max': 0,
+ 'avg': [],
+ })
+
+ if row['min'] > stat_row['salary_from']:
+ row['min'] = stat_row['salary_from']
+
+ if row['max'] < stat_row['salary_till']:
+ row['max'] = stat_row['salary_till']
+
+ if row['max'] < row['min']:
+ row['max'] = row['min']
+
+ row['avg'].append(
+ row['min'] + ((row['max'] - row['min']) / 2)
+ )
+
+ for info in by_currency.values():
+
+ info['avg'] = median(info['avg'])
+
+ for key in ('min', 'max', 'avg'):
+ info[key] = f'{round(info[key] / 1000, 1)}'.replace('.0', '', 1) + 'K'
+
+ return by_currency
+
+ def get_salary_str(self) -> str:
+ """Возвращает данные о зарплате в виде строки."""
+
+ chunks = []
+ if self.salary_from:
+ chunks.append(format_currency(self.salary_from))
+
+ if self.salary_till:
+ chunks.extend(('—', format_currency(self.salary_till)))
+
+ if self.salary_currency:
+ chunks.append(self.salary_currency)
+
+ return ' '.join(map(str, chunks)).strip()
+
+ def get_absolute_url(self, *, with_prefix: bool = False, utm_source: str = None) -> str:
+ return self.get_utm_url()
+
+ @classmethod
+ def update_statuses(cls):
+ """Обновляет состояния записей по данным внешнего ресурса."""
+
+ for_update = []
+ stale_time = now() - timedelta(days=30)
+
+ for vacancy in cls.objects.published():
+
+ source = cls.source_group.get_source(vacancy.src_alias)
+
+ if not source:
+ continue
+
+ for_update.append(vacancy)
+
+ status = source.get_status(vacancy.url_api)
+
+ if vacancy.time_created < stale_time:
+ vacancy.status = cls.Status.ARCHIVED
+
+ else:
+ if status:
+ vacancy.status = cls.Status.ARCHIVED
+
+ elif status is None:
+ vacancy.status = cls.Status.DELETED
+
+ cls.objects.bulk_update(for_update, fields=['status'])
diff --git a/pythonz/apps/models/version.py b/pythonz/apps/models/version.py
new file mode 100644
index 00000000..5f88a311
--- /dev/null
+++ b/pythonz/apps/models/version.py
@@ -0,0 +1,144 @@
+from datetime import timedelta
+from typing import NamedTuple
+
+from django.conf import settings
+from django.db import models
+from django.db.models import FloatField, QuerySet
+from django.db.models.functions import Cast
+from django.utils import timezone
+from etc.models import InheritedModel
+
+from ..generics.models import CommonEntityModel, ModelWithCompiledText, RealmBaseModel
+from .discussion import ModelWithDiscussions
+from .shared import HINT_IMPERSONAL_REQUIRED
+
+
+class LifeTimeInfo(NamedTuple):
+ idx: int
+ since: str
+ till: str
+ pos1: str
+ pos2: str
+
+
+class Version(InheritedModel, RealmBaseModel, CommonEntityModel, ModelWithDiscussions, ModelWithCompiledText):
+
+ slug_pick: bool = True
+ slug_auto: bool = True
+ items_per_page: int = 10
+
+ date = models.DateField('Дата выпуска')
+ date_till = models.DateField('Окончание поддержки', null=True, blank=True)
+
+ class Fields:
+
+ title = {
+ 'verbose_name': 'Номер',
+ 'help_text': 'Номер версии с двумя обязательными разрядами и третим опциональным. Например: 2.7.12, 3.6.',
+ }
+
+ description = {
+ 'verbose_name': 'Введение',
+ 'help_text': 'Краткое описание основных изменений в версии.',
+ }
+
+ text_src = {
+ 'verbose_name': 'Описание',
+ 'help_text': (
+ 'Обзорное, более полное описание нововведений и изменений, произошедших в версии. '
+ f'{HINT_IMPERSONAL_REQUIRED}'),
+ }
+
+ class Meta:
+
+ verbose_name = 'Версия Python'
+ verbose_name_plural = 'Версии Python'
+ ordering = ('-date',)
+
+ def __str__(self):
+ return f'Python {self.title}'
+
+ @property
+ def current(self) -> bool:
+ """Возвращает флаг, указывающий на то,
+ является ли версия актуальной (поддерживается ли на текущий момент).
+
+ """
+ date = self.date
+ date_till = self.date_till
+
+ if not date or not date_till:
+ return False
+
+ now = timezone.now().date()
+
+ return date < now < date_till
+
+ @property
+ def turbo_content(self) -> str:
+ return self.text
+
+ @classmethod
+ def get_paginator_objects(cls) -> QuerySet:
+ qs = super().get_paginator_objects()
+ qs = qs.order_by('-date')
+ return qs
+
+ @classmethod
+ def create_stub(cls, version_number: str) -> 'Version':
+ """Создаёт запись о версии, основываясь только на номере.
+ Использует для автоматического создания версий, например, из PEP.
+
+ :param version_number:
+
+ """
+ stub = cls(
+ title=version_number,
+ description=f'Python версии {version_number}',
+ text_src='Описание версии ещё не сформировано.',
+ submitter_id=settings.ROBOT_USER_ID,
+ date=timezone.now().date()
+ )
+ stub.save()
+
+ return stub
+
+ @classmethod
+ def get_lifetime_data(cls):
+
+ versions = cls.objects.exclude(
+ date_till__isnull=True
+ ).annotate(
+ num=Cast('title', FloatField())
+ ).order_by('num')
+
+ def format_date(val):
+ return val.strftime('%Y-%m-%d')
+
+ now = timezone.now().date()
+ delta = timedelta(days=1095) # 3 года
+
+ data = {
+ 'titles': [],
+ 'indexes': [],
+ 'info': [],
+ 'now': format_date(now),
+ 'range': f"'{format_date(now-delta)}', '{format_date(now+delta)}'"
+ }
+
+ next_pos = -0.1
+
+ for idx, version in enumerate(versions):
+ title = version.title
+ data['titles'].append(title)
+ data['indexes'].append(idx)
+ data['info'].append(LifeTimeInfo(
+ idx=idx,
+ since=format_date(version.date),
+ till=format_date(version.date_till),
+ pos1=f'{next_pos}',
+ pos2=f'{next_pos + 0.2}',
+ ))
+ next_pos += 1
+
+ return data
diff --git a/pythonz/apps/models/video.py b/pythonz/apps/models/video.py
new file mode 100644
index 00000000..34d7bf9d
--- /dev/null
+++ b/pythonz/apps/models/video.py
@@ -0,0 +1,68 @@
+
+from django.db import models
+from etc.models import InheritedModel
+from simple_history.models import HistoricalRecords
+from sitecats.models import ModelWithCategory
+
+from ..generics.models import CommonEntityModel, ModelWithAuthorAndTranslator, RealmBaseModel
+from ..integration.videos import VideoBroker
+from .discussion import ModelWithDiscussions
+from .person import PersonsLinked
+from .shared import HINT_IMPERSONAL_REQUIRED
+
+
+class Video(
+ InheritedModel, RealmBaseModel, CommonEntityModel, ModelWithDiscussions, ModelWithCategory,
+ ModelWithAuthorAndTranslator, PersonsLinked):
+ """Модель сущности `Видео`."""
+
+ COVER_UPLOAD_TO: str = 'videos'
+
+ code = models.TextField('Код')
+
+ url = models.URLField('URL')
+
+ authors = models.ManyToManyField('Person', verbose_name='Авторы', related_name='videos', blank=True)
+
+ history = HistoricalRecords()
+
+ persons_fields: list[str] = ['authors']
+
+ class Meta:
+
+ verbose_name = 'Видео'
+ verbose_name_plural = 'Видео'
+
+ class Fields:
+
+ title = 'Название видео'
+
+ translator = 'Перевод/озвучание'
+
+ description = {
+ 'help_text': f'Краткое описание того, о чём это видео. {HINT_IMPERSONAL_REQUIRED}',
+ }
+
+ linked = {
+ 'verbose_name': 'Связанные видео',
+ 'help_text': (
+ 'Выберите видео, которые имеют отношение к данному. '
+ 'Например, можно связать несколько эпизодов видео.'),
+ }
+
+ year = 'Год съёмок'
+
+ @property
+ def turbo_content(self) -> str:
+ return self.make_html(self.description)
+
+ @classmethod
+ def get_supported_hostings(cls) -> list[str]:
+ return list(VideoBroker.hostings.keys())
+
+ def update_code_and_cover(self, url: str):
+
+ embed_code, cover_url = VideoBroker.get_code_and_cover(url)
+
+ self.code = embed_code
+ self.update_cover_from_url(cover_url)
diff --git a/pythonz/apps/realms.py b/pythonz/apps/realms.py
new file mode 100644
index 00000000..ea9e48b9
--- /dev/null
+++ b/pythonz/apps/realms.py
@@ -0,0 +1,651 @@
+from collections.abc import Generator
+from operator import attrgetter
+
+from django.contrib.sitemaps import GenericSitemap
+from django.contrib.sitemaps.views import sitemap
+from django.db.models import signals
+from django.urls import get_resolver, path, re_path, reverse
+from sitecats.toolbox import get_tie_model
+from sitetree.models import TreeItemBase
+from sitetree.sitetreeapp import compose_dynamic_tree, register_dynamic_trees
+from sitetree.utils import item, tree
+
+from .forms.forms import (
+ AppForm,
+ ArticleForm,
+ BookForm,
+ CommunityForm,
+ DiscussionForm,
+ EventForm,
+ ReferenceForm,
+ UserForm,
+ VersionForm,
+ VideoForm,
+)
+from .generics.forms import CommonEntityForm
+from .generics.models import RealmBaseModel
+from .generics.realms import SYNDICATION_ITEMS_LIMIT, SYNDICATION_URL_MARKER, RealmBase
+from .generics.views import RealmView
+from .models import (
+ PEP,
+ App,
+ Article,
+ Book,
+ Category,
+ Community,
+ Discussion,
+ Event,
+ Person,
+ Place,
+ Reference,
+ User,
+ Vacancy,
+ Version,
+ Video,
+)
+from .signals import sig_support_changed
+from .views import (
+ CategoryListingView,
+ PepListingView,
+ PersonDetailsView,
+ PlaceDetailsView,
+ PlaceListingView,
+ ReferenceDetailsView,
+ ReferenceListingView,
+ UserDetailsView,
+ UserEditView,
+ VacancyListingView,
+ VersionDetailsView,
+ ide,
+)
+from .zen import register_zen_siteblock
+
+# Регистрируем блок сайта с дзеном
+register_zen_siteblock()
+
+
+def bootstrap_realms(urlpatterns: list) -> list:
+ """Инициализирует машинерию областей сайта.
+
+ Принимает на вход urlpatterns из urls.py и модифицирует их.
+
+ :param urlpatterns:
+
+ """
+ urlpatterns += get_realms_urls()
+
+ connect_signals()
+ build_sitetree()
+
+ return urlpatterns
+
+
+REALMS_REGISTRY: dict[str, type[RealmBase]] = {}
+
+
+def connect_signals():
+ """Подключает обработчки сигналов проекта."""
+
+ sig_support_changed.connect(RealmBaseModel.cache_delete_most_voted_objects)
+ signals.post_save.connect(ReferenceRealm.build_sitetree, sender=Reference)
+ signals.post_delete.connect(ReferenceRealm.build_sitetree, sender=Reference)
+
+
+def register_realms(*classes: type[RealmBase]):
+ """Регистрирует области (сущности), которые должны быть доступны на сайте.
+
+ :param classes:
+
+ """
+ for cls in classes:
+ REALMS_REGISTRY[cls.get_names()[0]] = cls
+ cls.init()
+
+
+def get_realms_models() -> list[type[RealmBaseModel]]:
+ """Возвращает список моделей всех областей сайта."""
+ return [r.model for r in get_realms().values()]
+
+
+def get_realms() -> dict[str, type[RealmBase]]:
+ """Возвращает словарь зарегистрированных областей сайта,
+ индексированный именами областей.
+
+ """
+ return REALMS_REGISTRY
+
+
+def get_realm(name: str) -> type[RealmBase] | None:
+ """Вернёт область по её имени, либо None.
+
+ :param name:
+
+ """
+ realms = get_realms()
+ realm = None
+
+ try:
+ realm = realms[name]
+
+ except KeyError:
+ pass
+
+ return realm
+
+
+def get_sitemaps() -> dict[str, GenericSitemap]:
+ """Возвращает словарь с sitemap-директивами для поисковых систем."""
+
+ sitemaps = {}
+
+ for realm in get_realms().values():
+
+ if realm.sitemap_enabled:
+ sitemaps[realm.name_plural] = realm.get_sitemap()
+
+ return sitemaps
+
+
+def get_realms_urls() -> list:
+ """Возвращает url-шаблоны всех зарегистрированных областей сайта."""
+
+ url_patterns = [
+ path('references/ide/', ide),
+ ]
+
+ for realm in get_realms().values():
+ url_patterns += realm.get_urls()
+
+ if sitemaps := get_sitemaps():
+ url_patterns += [re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps})]
+
+ return url_patterns
+
+
+def get_sitetree_root_item(children: Generator[TreeItemBase] = None) -> TreeItemBase:
+ """Возвращает корневой элемент динамического древа сайта.
+
+ :param children: Дочерние динамические элементы.
+
+ """
+ return item(
+ 'Про Python', '/', alias='topmenu', url_as_pattern=False,
+ description='Сайт про Питон. Различные материалы по языку программирования Python: '
+ 'книги, видео, справочник, сообщества, события, обсуждения и многое другое.',
+ children=children)
+
+
+def build_sitetree():
+ """Строит древо сайта, исходя из доступных областей сайта."""
+
+ register_dynamic_trees(
+ compose_dynamic_tree((
+ tree('main', 'Основное дерево', (
+ get_sitetree_root_item((realm.get_sitetree_items() for realm in get_realms().values())),
+ item('Вход', 'login', access_guest=True, in_menu=False, in_breadcrumbs=False),
+ item('', '/', alias='personal', url_as_pattern=False, access_loggedin=True, in_menu=False,
+ in_sitetree=False, children=(
+ item('Профиль', 'users:details request.user.id', access_loggedin=True, in_breadcrumbs=True,
+ in_sitetree=False),
+ item('Настройки', 'users:edit request.user.id', access_loggedin=True, in_breadcrumbs=True,
+ in_sitetree=False),
+ item('Выход', 'logout', access_loggedin=True, in_breadcrumbs=False, in_sitetree=False),
+ )),
+
+ )),
+ tree('about', 'О проекте', (
+ item('Что такое Python', '/promo/',
+ description='Краткие сведения о возможностях и областях применения языка программирования Python.',
+ url_as_pattern=False),
+ item('О проекте', '/about/',
+ description='Информация о проекте. О том, как, кем и для чего разрабатывается данный сайт.',
+ url_as_pattern=False),
+ item('Карта сайта', '/sitemap/', description='Список разделов на сайте оформленный в виде карты сайта.',
+ url_as_pattern=False),
+ item('Поиск по сайту', '/search/site/',
+ description='Глобальный поиск по материалам, расположенным на сайте.',
+ url_as_pattern=False),
+ item('Результаты поиска «{{ search_term }}»', '/search/', url_as_pattern=False, in_menu=False,
+ in_sitetree=False),
+ ))
+ )),
+ reset_cache=True
+ )
+
+ ReferenceRealm.build_sitetree()
+
+
+class BookRealm(RealmBase):
+ """Область с книгами."""
+
+ txt_form_add: str = 'Добавить книгу'
+ txt_form_edit: str = 'Изменить книгу'
+
+ view_listing_description: str = 'Книги по программированию вообще и на языке Python в частности.'
+ view_listing_keywords: str = 'книги по питону, литература по python'
+
+ model: type[RealmBaseModel] = Book
+ form: type[CommonEntityForm] = BookForm
+ icon: str = 'book'
+
+
+class VideoRealm(RealmBase):
+ """Область с видео."""
+
+ txt_form_add: str = 'Добавить видео'
+ txt_form_edit: str = 'Изменить видео'
+
+ view_listing_description: str = 'Видео-записи лекций, курсов, докладов, связанные с языком программирования Python'
+ view_listing_keywords: str = 'видео по питону, доклады по python'
+
+ model: type[RealmBaseModel] = Video
+ form: type[CommonEntityForm] = VideoForm
+ icon: str = 'film'
+
+
+class EventRealm(RealmBase):
+ """Область с событиями."""
+
+ view_listing_description: str = (
+ 'События, которые могут заинтересовать питонистов: встречи, конференции, спринты, и пр.')
+ view_listing_keywords: str = 'конференции по питону, встречи сообществ python'
+
+ txt_form_add: str = 'Добавить событие'
+ txt_form_edit: str = 'Изменить событие'
+
+ model: type[RealmBaseModel] = Event
+ form: type[CommonEntityForm] = EventForm
+ icon: str = 'calendar'
+
+
+class VacancyRealm(RealmBase):
+ """Область с вакансиями."""
+
+ allowed_views: tuple[str, ...] = ('listing',)
+ name_plural: str = 'vacancies'
+
+ show_on_main: bool = False
+
+ view_listing_description: str = 'Список вакансий, так или иначе связанных с языком программирования Python.'
+ view_listing_keywords: str = 'вакансии python, работа питон'
+
+ view_listing_base_class: type[RealmView] = VacancyListingView
+
+ model: type[RealmBaseModel] = Vacancy
+ icon: str = 'briefcase'
+ sitemap_enabled: bool = False
+
+
+class ReferenceRealm(RealmBase):
+ """Область со справочниками."""
+
+ allowed_views: tuple[str, ...] = ('listing', 'details', 'add', 'edit')
+
+ txt_form_add: str = 'Дополнить справочник'
+ txt_form_edit: str = 'Редактировать статью'
+
+ view_listing_description: str = 'Справочные и обучающие материалы по языку программирования Python.'
+ view_listing_keywords: str = 'справочник, питон, руководство, обучение, python 3, пайтон'
+
+ view_listing_base_class: type[RealmView] = ReferenceListingView
+ view_details_base_class: type[RealmView] = ReferenceDetailsView
+
+ model: type[RealmBaseModel] = Reference
+ form: type[CommonEntityForm] = ReferenceForm
+ icon: str = 'search'
+
+ @classmethod
+ def build_sitetree(cls, **kwargs):
+ """Строит динамическое дерево справочника под именем `references`."""
+ root_id = object()
+
+ root_item = get_sitetree_root_item()
+ temp_ref_items = {root_id: root_item}
+
+ ref_items = list(cls.model.get_actual().select_related('parent').only(
+ 'id', 'parent_id', 'parent__title', 'slug', 'status'
+ ).order_by('parent_id', 'title', 'id'))
+
+ def get_tree_item(ref_item):
+ item_id = getattr(ref_item, 'id', root_id)
+ tree_item = temp_ref_items.get(item_id)
+
+ if not tree_item:
+ tree_item = item(ref_item.title, ref_item.get_absolute_url(), url_as_pattern=False)
+ temp_ref_items[item_id] = tree_item
+
+ return tree_item
+
+ for ref_item in ref_items:
+ parent = get_tree_item(ref_item.parent)
+ child = get_tree_item(ref_item)
+
+ child.parent = parent
+ parent.dynamic_children.append(child)
+
+ register_dynamic_trees(compose_dynamic_tree([tree('references', items=[root_item])]), reset_cache=True)
+
+
+class ArticleRealm(RealmBase):
+ """Область со статьями."""
+
+ txt_form_add: str = 'Добавить статью'
+ txt_form_edit: str = 'Редактировать статью'
+
+ view_listing_description: str = 'Статьи и заметки, связанные с программированием Python и не только.'
+ view_listing_keywords: str = 'статьи про питон, материалы по python'
+
+ model: type[RealmBaseModel] = Article
+ form: type[CommonEntityForm] = ArticleForm
+ icon: str = 'file-o'
+
+
+class PlaceRealm(RealmBase):
+ """Область с географическими объектами (местами)."""
+
+ view_listing_description: str = 'Места, так или иначе связанные с языком программирования Python.'
+ view_listing_keywords: str = 'python в городе, где программируют на питоне'
+
+ view_listing_base_class: type[RealmView] = PlaceListingView
+ view_details_base_class: type[RealmView] = PlaceDetailsView
+
+ model: type[RealmBaseModel] = Place
+ form: type[CommonEntityForm] = VideoForm
+ icon: str = 'globe'
+
+ sitemap_changefreq: str = 'weekly'
+ allowed_views: tuple[str, ...] = ('listing', 'details')
+ show_on_main: bool = False
+ show_on_top: bool = False
+
+
+class DiscussionRealm(RealmBase):
+ """Область обсуждений."""
+
+ txt_form_add: str = 'Создать обсуждение'
+ txt_form_edit: str = 'Редактировать обсуждение'
+
+ view_listing_description: str = 'Обсуждения вопросов, связанных с программированием на Питоне.'
+ view_listing_keywords: str = 'вопросы по питону, обсуждения python, отзывы'
+
+ model: type[RealmBaseModel] = Discussion
+ form: type[CommonEntityForm] = DiscussionForm
+ icon: str = 'comments-o'
+
+ allowed_views: tuple[str, ...] = ('details', 'tags', 'add', 'edit')
+ ready_for_digest: bool = False
+ sitemap_enabled: bool = False
+ syndication_enabled: bool = False
+
+ show_on_main: bool = False
+ show_on_top: bool = False
+
+
+class UserRealm(RealmBase):
+ """Область с пользователями сайта."""
+
+ txt_form_edit: str = 'Изменить настройки'
+
+ view_listing_description: str = 'Список пользователей сайта.'
+ view_listing_keywords: str = 'питонисты, разработчики python, пользователи сайта'
+
+ view_details_base_class: type[RealmView] = UserDetailsView
+ view_edit_base_class: type[RealmView] = UserEditView
+
+ model: type[RealmBaseModel] = User
+ form: type[CommonEntityForm] = UserForm
+ icon: str = 'users'
+
+ sitemap_date_field: str = 'date_joined'
+ sitemap_changefreq: str = 'weekly'
+ allowed_views: tuple[str, ...] = ('details', 'edit')
+ ready_for_digest: bool = False
+ sitemap_enabled: bool = False
+ syndication_enabled: bool = False
+
+ show_on_main: bool = False
+ show_on_top: bool = False
+
+ @classmethod
+ def get_sitetree_details_item(cls) -> TreeItemBase:
+ return item('{{ user.get_display_name }}', 'users:details user.id', in_menu=False, in_sitetree=False)
+
+
+class CategoryRealm(RealmBase):
+ """Область с категориями."""
+
+ view_listing_description: str = 'Карта категорий материалов по языку программирования Python доступных на сайте.'
+ view_listing_keywords: str = 'материалы по питону, python по категориям'
+
+ model: type[RealmBaseModel] = Category
+ icon: str = 'tag'
+ name: str = 'category'
+ name_plural: str = 'categories'
+ allowed_views: tuple[str, ...] = ('listing', 'details')
+ ready_for_digest: bool = False
+ sitemap_enabled: bool = False
+
+ show_on_main: bool = False
+ show_on_top: bool = False
+
+ view_listing_title: str = 'Путеводитель'
+ view_listing_base_class: type[RealmView] = CategoryListingView
+ view_details_base_class: type[RealmView] = CategoryListingView
+
+ SYNDICATION_NAMESPACE: str = 'category_feeds'
+
+ @classmethod
+ def get_sitetree_details_item(cls) -> TreeItemBase:
+ return item(
+ 'Категория «{{ category.parent.title }} — {{ category.title }}»', 'categories:details category.id',
+ in_menu=False, in_sitetree=False)
+
+ @classmethod
+ def init(cls):
+ # Включаем прослушивание сигналов, необходимое для функционировая области.
+ tie_model = get_tie_model()
+ # url-синдикации будут обновлены в случае добавления/удаления связи сущности с категорией.
+ signals.post_save.connect(cls.update_syndication_urls, sender=tie_model)
+ signals.post_delete.connect(cls.update_syndication_urls, sender=tie_model)
+
+ @classmethod
+ def get_urls(cls) -> list:
+ urls = super().get_urls()
+ urls += CategoryRealm.get_syndication_urls()
+ return urls
+
+ @classmethod
+ def get_syndication_url(cls) -> str:
+ return 'feed/'
+
+ @classmethod
+ def update_syndication_urls(cls, **kwargs):
+ """Обновляет url-шаблоны синдикации, заменяя старые новыми."""
+
+ target_namespace = cls.SYNDICATION_NAMESPACE
+ linked_category_id_str = f"category_{kwargs['instance'].category_id}"
+ pattern_idx = -1
+
+ resolver = get_resolver(None)
+ urlpatterns = getattr(resolver.urlconf_module, 'urlpatterns', resolver.urlconf_module)
+
+ for idx, pattern in enumerate(urlpatterns):
+
+ if getattr(pattern, 'namespace', '') == target_namespace:
+ pattern_idx = idx
+
+ if linked_category_id_str in pattern.reverse_dict.keys():
+ # Категория была известна и ранее, перепривязка URL не требуется.
+ return
+
+ break
+
+ if pattern_idx > -1:
+ del urlpatterns[pattern_idx]
+ urlpatterns += cls.get_syndication_urls()
+
+ @classmethod
+ def get_syndication_urls(cls) -> list:
+ """Возвращает url-шаблоны с привязанными сгенерированными представлениями
+ для потоков синдикации (RSS) с перечислением новых материалов в категориях.
+
+ """
+ feeds = []
+ tie_model = get_tie_model()
+ categories = tie_model.get_linked_objects(by_category=True)
+
+ def get_in_category(category_id):
+ """Возвращает объекты из разных областей в указанной категории."""
+ linked = tie_model.get_linked_objects(filter_kwargs={'category_id': category_id}, id_only=True)
+ result = []
+
+ for model, ids in linked.items():
+ result.extend(model.get_actual().filter(id__in=ids)[:SYNDICATION_ITEMS_LIMIT])
+
+ result = sorted(result, key=attrgetter('time_published'), reverse=True)
+
+ return result[:SYNDICATION_ITEMS_LIMIT]
+
+ for category in categories.keys():
+
+ title = category.title
+ category_id = category.id
+ feed = RealmBase._get_syndication_feed(
+ title=title,
+ description=f'Материалы в категории «{title}». {category.note}',
+ func_link=lambda self: reverse(CategoryRealm.get_details_urlname(), args=[self.category_id]),
+ func_items=lambda self: get_in_category(self.category_id),
+ cls_name=f'Category{category_id}'
+ )
+ feed.category_id = category_id
+
+ feeds.append(
+ re_path(fr'^{category_id}/{SYNDICATION_URL_MARKER}/$', feed, name=f'category_{category_id}'))
+
+ _, realm_name_plural = CategoryRealm.get_names()
+
+ return [re_path(fr'^{realm_name_plural}/', (feeds, realm_name_plural, cls.SYNDICATION_NAMESPACE))]
+
+
+class CommunityRealm(RealmBase):
+ """Область с сообществами."""
+
+ txt_form_add: str = 'Добавить сообщество'
+ txt_form_edit: str = 'Редактировать сообщество'
+
+ view_listing_description: str = 'Сообщества людей интересующихся и занимающихся программированием на Питоне.'
+ view_listing_keywords: str = 'сообщества питонистов, программисты python'
+
+ name: str = 'community'
+ name_plural: str = 'communities'
+ model: type[RealmBaseModel] = Community
+ form: type[CommonEntityForm] = CommunityForm
+ icon: str = 'building-o'
+
+ show_on_main: bool = False
+ show_on_top: bool = False
+
+
+class VersionRealm(RealmBase):
+ """Область с версиями."""
+
+ txt_form_add: str = 'Добавить версию'
+ txt_form_edit: str = 'Редактировать версию'
+
+ view_listing_description: str = 'Вышедшие и будущие выпуски Python.'
+ view_listing_keywords: str = 'версии python, выпуски Питона'
+
+ allowed_views: tuple[str, ...] = ('listing', 'details', 'add', 'edit')
+ view_details_base_class: type[RealmView] = VersionDetailsView
+
+ name: str = 'version'
+ name_plural: str = 'versions'
+ model: type[RealmBaseModel] = Version
+ form: type[CommonEntityForm] = VersionForm
+ icon: str = 'code-fork'
+
+ show_on_top: bool = False
+ show_on_main: bool = False
+
+
+class PepRealm(RealmBase):
+ """Область с предложениями по улучшению."""
+
+ view_listing_description: str = 'Предложения по улучшению Питона (PEP).'
+ view_listing_keywords: str = 'python pep, преложения по улучшению, пепы, пеп'
+
+ view_listing_base_class: type[RealmView] = PepListingView
+
+ allowed_views: tuple[str, ...] = ('listing', 'details')
+
+ name: str = 'pep'
+ name_plural: str = 'peps'
+ model: type[RealmBaseModel] = PEP
+
+ icon: str = 'bell'
+
+ show_on_top: bool = False
+
+
+class PersonRealm(RealmBase):
+ """Область персон."""
+
+ view_listing_description: str = 'Персоны, тем или иным образом связанные с языком Python.'
+ view_listing_keywords: str = 'персоны python, питонисты, разработчики python'
+
+ allowed_views: tuple[str, ...] = ('listing', 'details')
+ view_details_base_class: type[RealmView] = PersonDetailsView
+
+ name: str = 'person'
+ name_plural: str = 'persons'
+ model: type[RealmBaseModel] = Person
+
+ icon: str = 'user'
+
+ show_on_main: bool = False
+ show_on_top: bool = False
+
+ syndication_enabled: bool = False
+ ready_for_digest: bool = False
+
+
+class AppRealm(RealmBase):
+ """Область приложений."""
+
+ txt_form_add: str = 'Добавить приложение'
+ txt_form_edit: str = 'Редактировать приложение'
+
+ view_listing_description: str = 'Приложения на Python.'
+ view_listing_keywords: str = 'программы на python, приложения на питоне'
+
+ name: str = 'app'
+ name_plural: str = 'apps'
+
+ model: type[RealmBaseModel] = App
+ form: type[CommonEntityForm] = AppForm
+
+ icon: str = 'tablet'
+
+ show_on_top: bool = False
+
+
+register_realms(
+ CategoryRealm,
+ ArticleRealm,
+ ReferenceRealm,
+ VideoRealm,
+ BookRealm,
+ VacancyRealm,
+ EventRealm,
+
+ PlaceRealm,
+ DiscussionRealm,
+ CommunityRealm,
+
+ VersionRealm,
+ PepRealm,
+ PersonRealm,
+ AppRealm,
+
+ UserRealm,
+)
diff --git a/pythonz/apps/settings.py b/pythonz/apps/settings.py
new file mode 100644
index 00000000..3d415a81
--- /dev/null
+++ b/pythonz/apps/settings.py
@@ -0,0 +1,30 @@
+"""Файл для siteprefs, чтобы настройки отображались в административном интерфейсе."""
+from django.conf import settings
+
+SOCKS5_PROXY = settings.SOCKS5_PROXY
+
+GOOGLE_API_KEY = settings.GOOGLE_API_KEY
+YANDEX_SEARCH_ID = settings.YANDEX_SEARCH_ID
+
+TELEGRAM_BOT_TOKEN = settings.TELEGRAM_BOT_TOKEN
+TELEGRAM_GROUP = settings.TELEGRAM_GROUP
+
+VK_ACCESS_TOKEN = settings.VK_ACCESS_TOKEN
+VK_GROUP = settings.VK_GROUP
+
+
+if 'siteprefs' in settings.INSTALLED_APPS:
+
+ from siteprefs.toolbox import preferences
+
+ with preferences() as prefs:
+
+ prefs(
+ SOCKS5_PROXY,
+ GOOGLE_API_KEY,
+ YANDEX_SEARCH_ID,
+ TELEGRAM_BOT_TOKEN,
+ TELEGRAM_GROUP,
+ VK_ACCESS_TOKEN,
+ VK_GROUP,
+ )
diff --git a/pythonz/apps/signals.py b/pythonz/apps/signals.py
new file mode 100644
index 00000000..990bd222
--- /dev/null
+++ b/pythonz/apps/signals.py
@@ -0,0 +1,28 @@
+import django.dispatch
+
+sig_entity_new = django.dispatch.Signal()
+"""Сигнализирует о добавлении новой сущности.
+Аргументы: entity
+
+"""
+
+sig_entity_published = django.dispatch.Signal()
+"""Сигнализирует о публикации новой сущности.
+Аргументы: entity
+
+"""
+
+sig_support_changed = django.dispatch.Signal()
+"""Сигнализирует о том, что пользователь проголосовал за материал или отозвал голос."""
+
+sig_integration_failed = django.dispatch.Signal()
+"""Сигнализирует о неисправности в процессах интеграции со внешними сервисами.
+Аргументы: description
+
+"""
+
+sig_send_generic_telegram = django.dispatch.Signal()
+"""Сингал отправки сообщения в Telegram.
+Аргументы: text
+
+"""
diff --git a/pythonz/apps/sitegates.py b/pythonz/apps/sitegates.py
new file mode 100644
index 00000000..db971b07
--- /dev/null
+++ b/pythonz/apps/sitegates.py
@@ -0,0 +1,9 @@
+from django.conf import settings
+from sitegate.signin_flows.remotes.google import Google
+from sitegate.signin_flows.remotes.yandex import Yandex
+from sitegate.utils import register_remotes
+
+register_remotes(
+ Yandex(client_id=settings.SITEGATE_REMOTES['yandex']),
+ Google(client_id=settings.SITEGATE_REMOTES['google']),
+)
diff --git a/apps/sitemessages.py b/pythonz/apps/sitemessages.py
similarity index 52%
rename from apps/sitemessages.py
rename to pythonz/apps/sitemessages.py
index 7cce070d..23c85b9a 100644
--- a/apps/sitemessages.py
+++ b/pythonz/apps/sitemessages.py
@@ -3,53 +3,63 @@
Конфигурирование производится в settings.py проекта.
"""
from datetime import datetime, timedelta
-from collections import OrderedDict
from functools import partial
-from sitemessage.utils import register_messenger_objects, register_message_types, override_message_type_for_app
-from sitemessage.messages.email import EmailHtmlMessage
-from sitemessage.messages.plain import PlainTextMessage
-from sitemessage.signals import sig_unsubscribe_failed, sig_unsubscribe_success
from django.conf import settings
from django.contrib import messages
+from django.db.models import QuerySet
from django.utils import timezone
-from django.utils.text import Truncator
+from sitemessage.exceptions import UnknownMessengerError
+from sitemessage.messages.email import EmailHtmlMessage
+from sitemessage.messages.plain import PlainTextMessage
+from sitemessage.signals import sig_unsubscribe_failed, sig_unsubscribe_success
+from sitemessage.utils import override_message_type_for_app, register_message_types, register_messenger_objects
-from .realms import get_realms, get_realm
-from .signals import sig_entity_published, sig_entity_new, sig_search_failed, sig_integration_failed
+from .generics.models import CommonEntityModel, RealmBaseModel
+from .generics.realms import RealmBase
+from .realms import get_realm, get_realms
+from .signals import sig_entity_new, sig_entity_published, sig_integration_failed, sig_send_generic_telegram
+from .utils import get_datetime_from_till
+Entity = CommonEntityModel | RealmBaseModel
-def register_messengers():
- """Регистрирует средства отсылки сообщений.
- :return:
- """
+def register_messengers():
+ """Регистрирует средства отсылки сообщений."""
SETTINGS = settings.SITEMESSAGES_SETTINGS
- SETTINGS_TWITTER = SETTINGS['twitter']
+ SOCKS5_PROXY = settings.SOCKS5_PROXY
+
SETTINGS_SMTP = SETTINGS['smtp']
+ SETTINGS_TELEGRAM = SETTINGS['telegram']
+ SETTINGS_VK = SETTINGS['vk']
messengers = []
- if SETTINGS_TWITTER:
- from sitemessage.messengers.twitter import TwitterMessenger
- messengers.append(TwitterMessenger(*SETTINGS_TWITTER))
if SETTINGS_SMTP:
from sitemessage.messengers.smtp import SMTPMessenger
messengers.append(SMTPMessenger(*SETTINGS_SMTP))
+ if SETTINGS_TELEGRAM:
+ from sitemessage.messengers.telegram import TelegramMessenger
+ messengers.append(TelegramMessenger(
+ *SETTINGS_TELEGRAM,
+ proxy={'https': f'socks5://{SOCKS5_PROXY}'} if SOCKS5_PROXY else None
+ ))
+
+ if SETTINGS_VK:
+ from sitemessage.messengers.vkontakte import VKontakteMessenger
+ messengers.append(VKontakteMessenger(*SETTINGS_VK))
+
if messengers:
register_messenger_objects(*messengers)
+
register_messengers()
def connect_signals():
- """Подключает обработчики сигналов, связанных с рассылками
- оповещений.
-
- :return:
- """
+ """Подключает обработчики сигналов, связанных с рассылками оповещений."""
def unsubscribe_failed(*args, **kwargs):
messages.error(kwargs['request'], 'К сожалению, отменить подписку не удалось.', 'danger error')
@@ -63,56 +73,81 @@ def unsubscribe_success(*args, **kwargs):
notify_handler = lambda sender, **kwargs: PythonzEmailNewEntity.create(kwargs['entity'])
sig_entity_new.connect(notify_handler, dispatch_uid='cfg_new_entity', weak=False)
- # Поиск без результатов.
- notify_handler = (
- lambda sender, **kwargs: PythonzEmailOneliner.create('Поиск без результатов', kwargs['search_term']))
- sig_search_failed.connect(notify_handler, dispatch_uid='cfg_search_failed', weak=False)
-
# Ошибка интеграции со сторонними сервисами.
notify_handler = (
lambda sender, **kwargs: PythonzEmailOneliner.create('Ошибка интеграции', kwargs['description']))
sig_integration_failed.connect(notify_handler, dispatch_uid='cfg_integration_failed', weak=False)
- if settings.DEBUG: # На всякий случай, чем чёрт не шутит.
- return False
+ # Сообщение в Телеграм.
+ notify_handler = lambda sender, **kwargs: PythonzTelegramMessage.create(kwargs['text'])
+ sig_send_generic_telegram.connect(notify_handler, dispatch_uid='cfg_telegram_generic', weak=False)
+
+ # Материал опубликован.
+ def notify_published(sender, **kwargs):
+
+ entity = kwargs['entity']
+
+ if not entity.notify_on_publish:
+ return False
+
+ try:
+ PythonzTelegramMessage.create_published(entity)
+ PythonzVkontakteMessage.create_published(entity)
+
+ except UnknownMessengerError:
+ # В режиме разработки рассыльные могут быть не сконфигурированы.
+
+ if settings.IN_PRODUCTION:
+ raise
+
+ sig_entity_published.connect(notify_published, dispatch_uid='cfg_entity_published', weak=False)
- notify_handler = lambda sender, **kwargs: PythonzTwitterMessage.create(kwargs['entity'])
- sig_entity_published.connect(notify_handler, dispatch_uid='cfg_entity_published', weak=False)
connect_signals()
-class PythonzTwitterMessage(PlainTextMessage):
- """Базовый класс для сообщений, рассылаемых pythonz в Twitter."""
+class PythonzBaseMessage(PlainTextMessage):
+ """Базовый класс для сообщений о новых материалах."""
+
+ alias: str = None
+ supported_messengers: list[str] = None
+
+ priority: int = 1 # Рассылается ежеминутно.
+ send_retry_limit: int = 5
+ title: str = 'Оповещение'
+ allow_user_subscription: bool = False
- priority = 1 # Рассылается ежеминутно.
- send_retry_limit = 5
- supported_messengers = ['twitter']
- title = 'Новое на сайте'
+
+class PythonzVkontakteMessage(PythonzBaseMessage):
+ """Класс для сообщений о новых материалах на сайте, публикуемых на стене в ВКонтакте."""
+
+ alias: str = 'vk_update'
+ supported_messengers: list[str] = ['vk']
@classmethod
- def create(cls, entity):
- """Создаёт оповещение о публикации сущности.
+ def create_published(cls, entity: Entity):
+ message = entity.get_absolute_url(with_prefix=True, utm_source='vk')
+ cls.create(message)
- :param RealmBaseModel entity:
- :return:
- """
+ @classmethod
+ def create(cls, message: str):
+ cls(message).schedule(cls.recipients('vk', settings.VK_GROUP))
- if not entity.notify_on_publish:
- return False
- MAX_LEN = 139 # Максимальная длина твита. Для верности меньше.
- prefix = 'Новое: %s «' % entity.get_verbose_name()
- url = entity.get_absolute_url(with_prefix=True, hash_chunk='fromtwee')
+class PythonzTelegramMessage(PythonzBaseMessage):
+ """Класс для сообщений о новых материалах на сайте, рассылаемых pythonz в Telegram."""
- postfix = '» %s' % url
- if settings.AGRESSIVE_MODE:
- postfix = '%s #python #dev' % postfix
+ alias: str = 'tele_update'
+ supported_messengers: list[str] = ['telegram']
- title = Truncator(entity.title).chars(MAX_LEN - len(prefix) - len(postfix))
- message = '%s%s%s' % (prefix, title, postfix)
+ @classmethod
+ def create_published(cls, entity: Entity):
+ message = f'Новое: {entity.get_verbose_name()} «{entity}» {entity.get_absolute_url(with_prefix=True)}'
+ cls.create(message)
- cls(message).schedule(cls.recipients('twitter', ''))
+ @classmethod
+ def create(cls, message: str):
+ cls(message).schedule(cls.recipients('telegram', settings.TELEGRAM_GROUP))
class PythonzEmailMessage(EmailHtmlMessage):
@@ -121,50 +156,48 @@ class PythonzEmailMessage(EmailHtmlMessage):
Этот же класс используется для рассылки писем от sitegate,
"""
+ alias: str = 'simple'
+ priority: int = 1 # Рассылается ежеминутно.
+ send_retry_limit: int = 4
+ title: str = 'Базовые оповещения'
+ allow_user_subscription: bool = False
- alias = 'simple'
- priority = 1 # Рассылается ежеминутно.
- send_retry_limit = 4
- title = 'Базовые оповещения'
+ def __init__(self, subject: str, html_or_dict: str | dict, template_path: str = None):
- def __init__(self, subject, html_or_dict, template_path=None):
if not isinstance(html_or_dict, dict):
html_or_dict = {'text': html_or_dict.replace('\n', ' ')}
+
super().__init__(subject, html_or_dict, template_path=template_path)
@classmethod
- def get_full_subject(cls, subject):
+ def get_full_subject(cls, subject: str) -> str:
"""Возвращает полный заголовок для электронного письма.
:param subject:
- :return:
+
"""
- return 'pythonz.net: %s' % subject
+ return f'pythonz.net: {subject}'
@classmethod
- def get_admins_emails(cls):
- """Возвращает адреса электронной почты администраторов проекта.
-
- :return:
- """
- to = []
- for item in settings.ADMINS:
- to.append(item[1]) # Адрес электронной почты админа.
- return to
+ def get_admins_emails(cls) -> list[str]:
+ """Возвращает адреса электронной почты администраторов проекта."""
+ return [item[1] for item in settings.ADMINS]
class PythonzEmailOneliner(PythonzEmailMessage):
"""Простое "однострочное" сообщение."""
+ group_mark = 'oneline'
+
@classmethod
- def create(cls, subject, text):
+ def create(cls, subject: str, text: str):
"""Создаёт оповещение общего вида.
Рассылается администраторам проекта.
:param subject: Заголовок.
:param text: Текст сообщения.
- :return:
+
"""
cls(cls.get_full_subject(subject), text).schedule(cls.recipients('smtp', cls.get_admins_emails()))
@@ -172,64 +205,73 @@ def create(cls, subject, text):
class PythonzEmailNewEntity(PythonzEmailMessage):
"""Оповещение администраторам о добавлении новой сущности."""
- alias = 'new_entity'
- title = 'Новое на сайте'
+ alias: str = 'new_entity'
+ title: str = 'Новое на сайте'
@classmethod
- def create(cls, entity):
+ def create(cls, entity: Entity):
"""Создаёт оповещение о создании новой сущности.
Рассылается администраторам проекта.
- :param RealmBaseModel entity:
- :return:
+ :param entity:
+
"""
if not entity.notify_on_publish:
return False
- subject = cls.get_full_subject('Новое - %s' % entity.title)
+ subject = cls.get_full_subject(f'Новое - {entity}')
+
context = {
- 'entity_title': entity.title,
+ 'entity_title': str(entity),
'entity_url': entity.get_absolute_url()
}
+
cls(subject, context).schedule(cls.recipients('smtp', cls.get_admins_emails()))
class PythonzEmailDigest(PythonzEmailMessage):
- """Класс реализующий рассылку с подборкой новых материалов сайта (дайджест)."""
+ """Класс реализующий рассылку с подборкой новых материалов сайта (сводку)."""
- alias = 'digest'
- priority = 7 # Рассылается раз в семь дней.
- send_retry_limit = 1 # Нет смысла пытаться повторно неделю спустя.
- title = 'Еженедельный дайджест'
+ alias: str = 'digest'
+ priority: int = 7 # Рассылается раз в семь дней.
+ send_retry_limit: int = 1 # Нет смысла пытаться повторно неделю спустя.
+ title: str = 'Еженедельная сводка'
+ allow_user_subscription: bool = True
@classmethod
def create(cls):
- """Создаёт депеши для рассылки еженедельного дайджеста.
+ """Создаёт депеши для рассылки еженедельной сводки.
- Реальная компиляция дайджеста происходит в compile().
+ Реальная компиляция сводки происходит в compile().
- :return:
"""
format_date = lambda d: d.date().strftime('%d.%m.%Y')
- date_till = timezone.now()
- date_from = date_till - timedelta(days=7)
+ date_from, date_till = get_datetime_from_till(7)
+
+ subject = cls.get_full_subject(f'Подборка материалов {format_date(date_from)}-{format_date(date_till)}')
- subject = cls.get_full_subject('Подборка материалов %s-%s' % (format_date(date_from), format_date(date_till)))
context = {
'date_from': date_from.timestamp(),
'date_till': date_till.timestamp()
}
+
cls(subject, context).schedule(cls.get_subscribers())
@classmethod
- def get_realms_data(cls, date_from, date_till, modified_mode=False):
+ def get_realms_data(
+ cls,
+ date_from: datetime,
+ date_till: datetime,
+ *,
+ modified_mode: bool = False
+ ) -> dict[str, QuerySet]:
"""Возвращает данные о материалах за указанный период.
- :param date date_from: Дата начала периода
- :param date date_till: Дата завершения периода
- :param bool modified_mode: Флаг. Следует ли возвращать данные об изменившихся материалах.
- :return:
+ :param date_from: Дата начала периода
+ :param date_till: Дата завершения периода
+ :param modified_mode: Флаг. Следует ли возвращать данные об изменившихся материалах.
+
"""
if modified_mode:
filter_kwargs = {
@@ -244,38 +286,39 @@ def get_realms_data(cls, date_from, date_till, modified_mode=False):
'time_published__lte': date_till
}
- realms_data = OrderedDict()
+ realms_data = {}
+
for realm in get_realms().values():
cls.extend_realms_data(realms_data, realm, filter_kwargs, 'time_published')
return realms_data
@classmethod
- def extend_realms_data(cls, realms_data, realm, filter_kwargs, order_by):
+ def extend_realms_data(cls, realms_data: dict, realm: type[RealmBase], filter_kwargs: dict, order_by: str):
"""Дополняет словарь с данными областей объектами из указанной области.
Требуемые объекты определяются указанным фильтром.
- :param OrderedDict realms_data: Словарь с данными.
- :param BaseRealm realm: Область.
- :param dict filter_kwargs: Фильтр для получения объектов области.
- :param str order_by: Имя поля, по которому следует отсортировать объекты.
- :return:
+ :param realms_data: Словарь с данными. Изменяется в ходе исполнения метода.
+ :param realm: Область.
+ :param filter_kwargs: Фильтр для получения объектов области.
+ :param order_by: Имя поля, по которому следует отсортировать объекты.
+
"""
if realm.ready_for_digest:
- entries = realm.model.get_actual().filter(**filter_kwargs).order_by(order_by)
- if entries:
+
+ if entries := realm.model.get_actual().filter(**filter_kwargs).order_by(order_by):
+
for entry in entries:
- entry.absolute_url = entry.get_absolute_url(with_prefix=True, hash_chunk='frommail')
+ entry.absolute_url = entry.get_absolute_url(with_prefix=True, utm_source='mail')
+
realms_data[realm.model.get_verbose_name_plural()] = entries
@classmethod
- def get_upcoming_items(cls):
+ def get_upcoming_items(cls) -> dict[str, QuerySet]:
"""Возвращает данные о материалах которые вскоре станут актульными.
Например, о грядущих событиях.
- :return:
"""
-
date_from = timezone.now()
date_till = date_from + timedelta(days=10) # 10 дней, чтобы покрыть остаток текущей и следующую неделю.
@@ -284,7 +327,7 @@ def get_upcoming_items(cls):
'time_start__lte': date_till
}
- realms_data = OrderedDict()
+ realms_data = {}
realm = get_realm('event')
cls.extend_realms_data(realms_data, realm, filter_kwargs, 'time_start')
@@ -292,36 +335,36 @@ def get_upcoming_items(cls):
return realms_data
@classmethod
- def get_template_context(cls, context):
+ def get_template_context(cls, context: dict) -> dict:
"""Заполняет шаблон сообщения данными.
:param context:
- :return:
+
"""
get_date = partial(datetime.fromtimestamp, tz=timezone.get_current_timezone())
date_from = get_date(context.get('date_from'))
date_till = get_date(context.get('date_till'))
- realms_data = OrderedDict()
- objects_new = cls.get_realms_data(date_from, date_till)
- if objects_new:
+ realms_data = {}
+
+ if objects_upcoming := cls.get_upcoming_items():
+ realms_data['Скоро'] = objects_upcoming
+
+ if objects_new := cls.get_realms_data(date_from, date_till):
realms_data['Новые'] = objects_new
- objects_modified = cls.get_realms_data(date_from, date_till, modified_mode=True)
- if objects_modified:
+ if objects_modified := cls.get_realms_data(date_from, date_till, modified_mode=True):
realms_data['Изменившиеся'] = objects_modified
- objects_upcoming = cls.get_upcoming_items()
- if objects_upcoming:
- realms_data['Скоро'] = objects_upcoming
-
context.update({'realms_data': realms_data})
+
return context
# Регистрируем наши типы сообщений.
register_message_types(
- PythonzTwitterMessage,
+ PythonzTelegramMessage,
+ PythonzVkontakteMessage,
PythonzEmailMessage,
PythonzEmailOneliner,
PythonzEmailNewEntity,
diff --git a/pythonz/apps/static/css/bootstrap.css b/pythonz/apps/static/css/bootstrap.css
new file mode 100644
index 00000000..3098a1b8
--- /dev/null
+++ b/pythonz/apps/static/css/bootstrap.css
@@ -0,0 +1,5 @@
+/*!
+ * Bootstrap v3.3.5 (http://getbootstrap.com)
+ * Copyright 2011-2015 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,*:before,*:after{background:transparent!important;color:#000!important;box-shadow:none!important;text-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="#"]:after,a[href^="javascript:"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100%!important}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered th,.table-bordered td{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url('../fonts/glyphicons-halflings-regular.eot');src:url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'),url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'),url('../fonts/glyphicons-halflings-regular.woff') format('woff'),url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'),url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:normal;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\2a"}.glyphicon-plus:before{content:"\2b"}.glyphicon-euro:before,.glyphicon-eur:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}*:before,*:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:Verdana,sans-serif;font-size:14px;line-height:1.428571429;color:#000;background-color:#fff}input,button,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#3776ab;text-decoration:none}a:hover,a:focus{color:#244e71;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.img-responsive,.thumbnail>img,.thumbnail a>img,.carousel-inner>.item>img,.carousel-inner>.item>a>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{padding:4px;line-height:1.428571429;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out;display:inline-block;max-width:100%;height:auto}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #c3c3c3}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role="button"]{cursor:pointer}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small,.h1 small,.h2 small,.h3 small,.h4 small,.h5 small,.h6 small,h1 .small,h2 .small,h3 .small,h4 .small,h5 .small,h6 .small,.h1 .small,.h2 .small,.h3 .small,.h4 .small,.h5 .small,.h6 .small{font-weight:normal;line-height:1;color:#777}h1,.h1,h2,.h2,h3,.h3{margin-top:20px;margin-bottom:10px}h1 small,.h1 small,h2 small,.h2 small,h3 small,.h3 small,h1 .small,.h1 .small,h2 .small,.h2 .small,h3 .small,.h3 .small{font-size:65%}h4,.h4,h5,.h5,h6,.h6{margin-top:10px;margin-bottom:10px}h4 small,.h4 small,h5 small,.h5 small,h6 small,.h6 small,h4 .small,.h4 .small,h5 .small,.h5 .small,h6 .small,.h6 .small{font-size:75%}h1,.h1{font-size:30px}h2,.h2{font-size:30px}h3,.h3{font-size:24px}h4,.h4{font-size:18px}h5,.h5{font-size:14px}h6,.h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media(min-width:768px){.lead{font-size:21px}}small,.small{font-size:78%}mark,.mark{background-color:#fcf8e3;padding:.2em}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#82b043}a.text-primary:hover,a.text-primary:focus{color:#678b35}.text-success{color:#678b35}a.text-success:hover,a.text-success:focus{color:#4b6627}.text-info{color:#3776ab}a.text-info:hover,a.text-info:focus{color:#2b5b84}.text-warning{color:#c09853}a.text-warning:hover,a.text-warning:focus{color:#a47e3c}.text-danger{color:#b94a48}a.text-danger:hover,a.text-danger:focus{color:#953b39}.bg-primary{color:#fff;background-color:#82b043}a.bg-primary:hover,a.bg-primary:focus{background-color:#678b35}.bg-success{background-color:#e2eed1}a.bg-success:hover,a.bg-success:focus{background-color:#cae0ac}.bg-info{background-color:#c2d9ec}a.bg-info:hover,a.bg-info:focus{background-color:#9cc0df}.bg-warning{background-color:#fcf8e3}a.bg-warning:hover,a.bg-warning:focus{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:hover,a.bg-danger:focus{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ul,ol{margin-top:0;margin-bottom:10px}ul ul,ol ul,ul ol,ol ol{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none;margin-left:-5px}.list-inline>li{display:inline-block;padding-left:5px;padding-right:5px}dl{margin-top:0;margin-bottom:20px}dt,dd{line-height:1.428571429}dt{font-weight:bold}dd{margin-left:0}@media(min-width:768px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[title],abbr[data-original-title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:14px;border-left:5px solid #eee}blockquote p:last-child,blockquote ul:last-child,blockquote ol:last-child{margin-bottom:0}blockquote footer,blockquote small,blockquote .small{display:block;font-size:80%;line-height:1.428571429;color:#777}blockquote footer:before,blockquote small:before,blockquote .small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #eee;border-left:0;text-align:right}.blockquote-reverse footer:before,blockquote.pull-right footer:before,.blockquote-reverse small:before,blockquote.pull-right small:before,.blockquote-reverse .small:before,blockquote.pull-right .small:before{content:''}.blockquote-reverse footer:after,blockquote.pull-right footer:after,.blockquote-reverse small:after,blockquote.pull-right small:after,.blockquote-reverse .small:after,blockquote.pull-right .small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.428571429}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#3776ab;background-color:#e9f1f8;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#4f90c6;border-radius:3px;box-shadow:inset 0 -1px 0 rgba(0,0,0,0.25)}kbd kbd{padding:0;font-size:100%;font-weight:bold;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.428571429;word-break:break-all;word-wrap:break-word;color:#333;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}@media(min-width:768px){.container{width:750px}}@media(min-width:992px){.container{width:970px}}@media(min-width:1200px){.container{width:1170px}}.container-fluid{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.row{margin-left:-15px;margin-right:-15px}.col-xs-1,.col-sm-1,.col-md-1,.col-lg-1,.col-xs-2,.col-sm-2,.col-md-2,.col-lg-2,.col-xs-3,.col-sm-3,.col-md-3,.col-lg-3,.col-xs-4,.col-sm-4,.col-md-4,.col-lg-4,.col-xs-5,.col-sm-5,.col-md-5,.col-lg-5,.col-xs-6,.col-sm-6,.col-md-6,.col-lg-6,.col-xs-7,.col-sm-7,.col-md-7,.col-lg-7,.col-xs-8,.col-sm-8,.col-md-8,.col-lg-8,.col-xs-9,.col-sm-9,.col-md-9,.col-lg-9,.col-xs-10,.col-sm-10,.col-md-10,.col-lg-10,.col-xs-11,.col-sm-11,.col-md-11,.col-lg-11,.col-xs-12,.col-sm-12,.col-md-12,.col-lg-12{position:relative;min-height:1px;padding-left:15px;padding-right:15px}.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666666666666%}.col-xs-10{width:83.33333333333334%}.col-xs-9{width:75%}.col-xs-8{width:66.66666666666666%}.col-xs-7{width:58.333333333333336%}.col-xs-6{width:50%}.col-xs-5{width:41.66666666666667%}.col-xs-4{width:33.33333333333333%}.col-xs-3{width:25%}.col-xs-2{width:16.666666666666664%}.col-xs-1{width:8.333333333333332%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666666666666%}.col-xs-pull-10{right:83.33333333333334%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666666666666%}.col-xs-pull-7{right:58.333333333333336%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666666666667%}.col-xs-pull-4{right:33.33333333333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.666666666666664%}.col-xs-pull-1{right:8.333333333333332%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666666666666%}.col-xs-push-10{left:83.33333333333334%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666666666666%}.col-xs-push-7{left:58.333333333333336%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666666666667%}.col-xs-push-4{left:33.33333333333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.666666666666664%}.col-xs-push-1{left:8.333333333333332%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666666666666%}.col-xs-offset-10{margin-left:83.33333333333334%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666666666666%}.col-xs-offset-7{margin-left:58.333333333333336%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666666666667%}.col-xs-offset-4{margin-left:33.33333333333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.666666666666664%}.col-xs-offset-1{margin-left:8.333333333333332%}.col-xs-offset-0{margin-left:0}@media(min-width:768px){.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666666666666%}.col-sm-10{width:83.33333333333334%}.col-sm-9{width:75%}.col-sm-8{width:66.66666666666666%}.col-sm-7{width:58.333333333333336%}.col-sm-6{width:50%}.col-sm-5{width:41.66666666666667%}.col-sm-4{width:33.33333333333333%}.col-sm-3{width:25%}.col-sm-2{width:16.666666666666664%}.col-sm-1{width:8.333333333333332%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666666666666%}.col-sm-pull-10{right:83.33333333333334%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666666666666%}.col-sm-pull-7{right:58.333333333333336%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666666666667%}.col-sm-pull-4{right:33.33333333333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.666666666666664%}.col-sm-pull-1{right:8.333333333333332%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666666666666%}.col-sm-push-10{left:83.33333333333334%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666666666666%}.col-sm-push-7{left:58.333333333333336%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666666666667%}.col-sm-push-4{left:33.33333333333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.666666666666664%}.col-sm-push-1{left:8.333333333333332%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666666666666%}.col-sm-offset-10{margin-left:83.33333333333334%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666666666666%}.col-sm-offset-7{margin-left:58.333333333333336%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666666666667%}.col-sm-offset-4{margin-left:33.33333333333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.666666666666664%}.col-sm-offset-1{margin-left:8.333333333333332%}.col-sm-offset-0{margin-left:0}}@media(min-width:992px){.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666666666666%}.col-md-10{width:83.33333333333334%}.col-md-9{width:75%}.col-md-8{width:66.66666666666666%}.col-md-7{width:58.333333333333336%}.col-md-6{width:50%}.col-md-5{width:41.66666666666667%}.col-md-4{width:33.33333333333333%}.col-md-3{width:25%}.col-md-2{width:16.666666666666664%}.col-md-1{width:8.333333333333332%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666666666666%}.col-md-pull-10{right:83.33333333333334%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666666666666%}.col-md-pull-7{right:58.333333333333336%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666666666667%}.col-md-pull-4{right:33.33333333333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.666666666666664%}.col-md-pull-1{right:8.333333333333332%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666666666666%}.col-md-push-10{left:83.33333333333334%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666666666666%}.col-md-push-7{left:58.333333333333336%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666666666667%}.col-md-push-4{left:33.33333333333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.666666666666664%}.col-md-push-1{left:8.333333333333332%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666666666666%}.col-md-offset-10{margin-left:83.33333333333334%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666666666666%}.col-md-offset-7{margin-left:58.333333333333336%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666666666667%}.col-md-offset-4{margin-left:33.33333333333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.666666666666664%}.col-md-offset-1{margin-left:8.333333333333332%}.col-md-offset-0{margin-left:0}}@media(min-width:1200px){.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666666666666%}.col-lg-10{width:83.33333333333334%}.col-lg-9{width:75%}.col-lg-8{width:66.66666666666666%}.col-lg-7{width:58.333333333333336%}.col-lg-6{width:50%}.col-lg-5{width:41.66666666666667%}.col-lg-4{width:33.33333333333333%}.col-lg-3{width:25%}.col-lg-2{width:16.666666666666664%}.col-lg-1{width:8.333333333333332%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666666666666%}.col-lg-pull-10{right:83.33333333333334%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666666666666%}.col-lg-pull-7{right:58.333333333333336%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666666666667%}.col-lg-pull-4{right:33.33333333333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.666666666666664%}.col-lg-pull-1{right:8.333333333333332%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666666666666%}.col-lg-push-10{left:83.33333333333334%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666666666666%}.col-lg-push-7{left:58.333333333333336%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666666666667%}.col-lg-push-4{left:33.33333333333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.666666666666664%}.col-lg-push-1{left:8.333333333333332%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666666666666%}.col-lg-offset-10{margin-left:83.33333333333334%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666666666666%}.col-lg-offset-7{margin-left:58.333333333333336%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666666666667%}.col-lg-offset-4{margin-left:33.33333333333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.666666666666664%}.col-lg-offset-1{margin-left:8.333333333333332%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>thead>tr>th,.table>tbody>tr>th,.table>tfoot>tr>th,.table>thead>tr>td,.table>tbody>tr>td,.table>tfoot>tr>td{padding:8px;line-height:1.428571429;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>th,.table>caption+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>td,.table>thead:first-child>tr:first-child>td{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>thead>tr>th,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>tbody>tr>td,.table-condensed>tfoot>tr>td{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>td{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*="col-"]{position:static;float:none;display:table-column}table td[class*="col-"],table th[class*="col-"]{position:static;float:none;display:table-cell}.table>thead>tr>td.active,.table>tbody>tr>td.active,.table>tfoot>tr>td.active,.table>thead>tr>th.active,.table>tbody>tr>th.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>tbody>tr.active>td,.table>tfoot>tr.active>td,.table>thead>tr.active>th,.table>tbody>tr.active>th,.table>tfoot>tr.active>th{background-color:#f5f5f5}.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover,.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr.active:hover>th{background-color:#e8e8e8}.table>thead>tr>td.success,.table>tbody>tr>td.success,.table>tfoot>tr>td.success,.table>thead>tr>th.success,.table>tbody>tr>th.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>tbody>tr.success>td,.table>tfoot>tr.success>td,.table>thead>tr.success>th,.table>tbody>tr.success>th,.table>tfoot>tr.success>th{background-color:#e2eed1}.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover,.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr.success:hover>th{background-color:#d6e7bf}.table>thead>tr>td.info,.table>tbody>tr>td.info,.table>tfoot>tr>td.info,.table>thead>tr>th.info,.table>tbody>tr>th.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>tbody>tr.info>td,.table>tfoot>tr.info>td,.table>thead>tr.info>th,.table>tbody>tr.info>th,.table>tfoot>tr.info>th{background-color:#c2d9ec}.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover,.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr.info:hover>th{background-color:#afcde5}.table>thead>tr>td.warning,.table>tbody>tr>td.warning,.table>tfoot>tr>td.warning,.table>thead>tr>th.warning,.table>tbody>tr>th.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>tbody>tr.warning>td,.table>tfoot>tr.warning>td,.table>thead>tr.warning>th,.table>tbody>tr.warning>th,.table>tfoot>tr.warning>th{background-color:#fcf8e3}.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover,.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr.warning:hover>th{background-color:#faf2cc}.table>thead>tr>td.danger,.table>tbody>tr>td.danger,.table>tfoot>tr>td.danger,.table>thead>tr>th.danger,.table>tbody>tr>th.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>tbody>tr.danger>td,.table>tfoot>tr.danger>td,.table>thead>tr.danger>th,.table>tbody>tr.danger>th,.table>tfoot>tr.danger>th{background-color:#f2dede}.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover,.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr.danger:hover>th{background-color:#ebcccc}.table-responsive{overflow-x:auto;min-height:.01%}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>thead>tr>th,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tfoot>tr>td{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>thead>tr>th:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.table-responsive>.table-bordered>thead>tr>th:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>th,.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}}fieldset{padding:0;margin:0;border:0;min-width:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:bold}input[type="search"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type="radio"],input[type="checkbox"]{margin:4px 0 0;margin-top:1px \9;line-height:normal}input[type="file"]{display:block}input[type="range"]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type="file"]:focus,input[type="radio"]:focus,input[type="checkbox"]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.428571429;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.428571429;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-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-color ease-in-out .15s,box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,0.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type="search"]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type="date"].form-control,input[type="time"].form-control,input[type="datetime-local"].form-control,input[type="month"].form-control{line-height:34px}input[type="date"].input-sm,input[type="time"].input-sm,input[type="datetime-local"].input-sm,input[type="month"].input-sm,.input-group-sm input[type="date"],.input-group-sm input[type="time"],.input-group-sm input[type="datetime-local"],.input-group-sm input[type="month"]{line-height:28px}input[type="date"].input-lg,input[type="time"].input-lg,input[type="datetime-local"].input-lg,input[type="month"].input-lg,.input-group-lg input[type="date"],.input-group-lg input[type="time"],.input-group-lg input[type="datetime-local"],.input-group-lg input[type="month"]{line-height:46px}}.form-group{margin-bottom:15px}.radio,.checkbox{position:relative;display:block;margin-top:10px;margin-bottom:10px}.radio label,.checkbox label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:normal;cursor:pointer}.radio input[type="radio"],.radio-inline input[type="radio"],.checkbox input[type="checkbox"],.checkbox-inline input[type="checkbox"]{position:absolute;margin-left:-20px;margin-top:4px \9}.radio+.radio,.checkbox+.checkbox{margin-top:-5px}.radio-inline,.checkbox-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;vertical-align:middle;font-weight:normal;cursor:pointer}.radio-inline+.radio-inline,.checkbox-inline+.checkbox-inline{margin-top:0;margin-left:10px}input[type="radio"][disabled],input[type="checkbox"][disabled],input[type="radio"].disabled,input[type="checkbox"].disabled,fieldset[disabled] input[type="radio"],fieldset[disabled] input[type="checkbox"]{cursor:not-allowed}.radio-inline.disabled,.checkbox-inline.disabled,fieldset[disabled] .radio-inline,fieldset[disabled] .checkbox-inline{cursor:not-allowed}.radio.disabled label,.checkbox.disabled label,fieldset[disabled] .radio label,fieldset[disabled] .checkbox label{cursor:not-allowed}.form-control-static{padding-top:7px;padding-bottom:7px;margin-bottom:0;min-height:34px}.form-control-static.input-lg,.form-control-static.input-sm{padding-left:0;padding-right:0}.input-sm{height:28px;padding:5px 10px;font-size:11px;line-height:1.5;border-radius:3px}select.input-sm{height:28px;line-height:28px}textarea.input-sm,select[multiple].input-sm{height:auto}.form-group-sm .form-control{height:28px;padding:5px 10px;font-size:11px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:28px;line-height:28px}.form-group-sm textarea.form-control,.form-group-sm select[multiple].form-control{height:auto}.form-group-sm .form-control-static{height:28px;min-height:31px;padding:6px 10px;font-size:11px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}textarea.input-lg,select[multiple].input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg textarea.form-control,.form-group-lg select[multiple].form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.input-lg+.form-control-feedback,.input-group-lg+.form-control-feedback,.form-group-lg .form-control+.form-control-feedback{width:46px;height:46px;line-height:46px}.input-sm+.form-control-feedback,.input-group-sm+.form-control-feedback,.form-group-sm .form-control+.form-control-feedback{width:28px;height:28px;line-height:28px}.has-success .help-block,.has-success .control-label,.has-success .radio,.has-success .checkbox,.has-success .radio-inline,.has-success .checkbox-inline,.has-success.radio label,.has-success.checkbox label,.has-success.radio-inline label,.has-success.checkbox-inline label{color:#678b35}.has-success .form-control{border-color:#678b35;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-success .form-control:focus{border-color:#4b6627;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #9bc363;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #9bc363}.has-success .input-group-addon{color:#678b35;border-color:#678b35;background-color:#e2eed1}.has-success .form-control-feedback{color:#678b35}.has-warning .help-block,.has-warning .control-label,.has-warning .radio,.has-warning .checkbox,.has-warning .radio-inline,.has-warning .checkbox-inline,.has-warning.radio label,.has-warning.checkbox label,.has-warning.radio-inline label,.has-warning.checkbox-inline label{color:#c09853}.has-warning .form-control{border-color:#c09853;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-warning .form-control:focus{border-color:#a47e3c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #dbc59e;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #dbc59e}.has-warning .input-group-addon{color:#c09853;border-color:#c09853;background-color:#fcf8e3}.has-warning .form-control-feedback{color:#c09853}.has-error .help-block,.has-error .control-label,.has-error .radio,.has-error .checkbox,.has-error .radio-inline,.has-error .checkbox-inline,.has-error.radio label,.has-error.checkbox label,.has-error.radio-inline label,.has-error.checkbox-inline label{color:#b94a48}.has-error .form-control{border-color:#b94a48;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-error .form-control:focus{border-color:#953b39;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #d59392;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #d59392}.has-error .input-group-addon{color:#b94a48;border-color:#b94a48;background-color:#f2dede}.has-error .form-control-feedback{color:#b94a48}.has-feedback label ~ .form-control-feedback{top:25px}.has-feedback label.sr-only ~ .form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#404040}@media(min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn,.form-inline .input-group .form-control{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .radio,.form-inline .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .radio label,.form-inline .checkbox label{padding-left:0}.form-inline .radio input[type="radio"],.form-inline .checkbox input[type="checkbox"]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .radio,.form-horizontal .checkbox,.form-horizontal .radio-inline,.form-horizontal .checkbox-inline{margin-top:0;margin-bottom:0;padding-top:7px}.form-horizontal .radio,.form-horizontal .checkbox{min-height:27px}.form-horizontal .form-group{margin-left:-15px;margin-right:-15px}@media(min-width:768px){.form-horizontal .control-label{text-align:right;margin-bottom:0;padding-top:7px}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media(min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:14.333333px;font-size:18px}}@media(min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:11px}}.btn{display:inline-block;margin-bottom:0;font-weight:normal;text-align:center;vertical-align:middle;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;padding:6px 12px;font-size:14px;line-height:1.428571429;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.btn:focus,.btn:active:focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn.active.focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn:hover,.btn:focus,.btn.focus{color:#333;text-decoration:none}.btn:active,.btn.active{outline:0;background-image:none;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;opacity:.65;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default:focus,.btn-default.focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active,.btn-default.active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active:hover,.btn-default.active:hover,.open>.dropdown-toggle.btn-default:hover,.btn-default:active:focus,.btn-default.active:focus,.open>.dropdown-toggle.btn-default:focus,.btn-default:active.focus,.btn-default.active.focus,.open>.dropdown-toggle.btn-default.focus{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default:active,.btn-default.active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled,.btn-default[disabled],fieldset[disabled] .btn-default,.btn-default.disabled:hover,.btn-default[disabled]:hover,fieldset[disabled] .btn-default:hover,.btn-default.disabled:focus,.btn-default[disabled]:focus,fieldset[disabled] .btn-default:focus,.btn-default.disabled.focus,.btn-default[disabled].focus,fieldset[disabled] .btn-default.focus,.btn-default.disabled:active,.btn-default[disabled]:active,fieldset[disabled] .btn-default:active,.btn-default.disabled.active,.btn-default[disabled].active,fieldset[disabled] .btn-default.active{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#82b043;border-color:#749e3c}.btn-primary:focus,.btn-primary.focus{color:#fff;background-color:#678b35;border-color:#304119}.btn-primary:hover{color:#fff;background-color:#678b35;border-color:#54712b}.btn-primary:active,.btn-primary.active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#678b35;border-color:#54712b}.btn-primary:active:hover,.btn-primary.active:hover,.open>.dropdown-toggle.btn-primary:hover,.btn-primary:active:focus,.btn-primary.active:focus,.open>.dropdown-toggle.btn-primary:focus,.btn-primary:active.focus,.btn-primary.active.focus,.open>.dropdown-toggle.btn-primary.focus{color:#fff;background-color:#54712b;border-color:#304119}.btn-primary:active,.btn-primary.active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled,.btn-primary[disabled],fieldset[disabled] .btn-primary,.btn-primary.disabled:hover,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary:hover,.btn-primary.disabled:focus,.btn-primary[disabled]:focus,fieldset[disabled] .btn-primary:focus,.btn-primary.disabled.focus,.btn-primary[disabled].focus,fieldset[disabled] .btn-primary.focus,.btn-primary.disabled:active,.btn-primary[disabled]:active,fieldset[disabled] .btn-primary:active,.btn-primary.disabled.active,.btn-primary[disabled].active,fieldset[disabled] .btn-primary.active{background-color:#82b043;border-color:#749e3c}.btn-primary .badge{color:#82b043;background-color:#fff}.btn-success{color:#fff;background-color:#82b043;border-color:#749e3c}.btn-success:focus,.btn-success.focus{color:#fff;background-color:#678b35;border-color:#304119}.btn-success:hover{color:#fff;background-color:#678b35;border-color:#54712b}.btn-success:active,.btn-success.active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#678b35;border-color:#54712b}.btn-success:active:hover,.btn-success.active:hover,.open>.dropdown-toggle.btn-success:hover,.btn-success:active:focus,.btn-success.active:focus,.open>.dropdown-toggle.btn-success:focus,.btn-success:active.focus,.btn-success.active.focus,.open>.dropdown-toggle.btn-success.focus{color:#fff;background-color:#54712b;border-color:#304119}.btn-success:active,.btn-success.active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled,.btn-success[disabled],fieldset[disabled] .btn-success,.btn-success.disabled:hover,.btn-success[disabled]:hover,fieldset[disabled] .btn-success:hover,.btn-success.disabled:focus,.btn-success[disabled]:focus,fieldset[disabled] .btn-success:focus,.btn-success.disabled.focus,.btn-success[disabled].focus,fieldset[disabled] .btn-success.focus,.btn-success.disabled:active,.btn-success[disabled]:active,fieldset[disabled] .btn-success:active,.btn-success.disabled.active,.btn-success[disabled].active,fieldset[disabled] .btn-success.active{background-color:#82b043;border-color:#749e3c}.btn-success .badge{color:#82b043;background-color:#fff}.btn-info{color:#fff;background-color:#3776ab;border-color:#316998}.btn-info:focus,.btn-info.focus{color:#fff;background-color:#2b5b84;border-color:#122637}.btn-info:hover{color:#fff;background-color:#2b5b84;border-color:#224969}.btn-info:active,.btn-info.active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#2b5b84;border-color:#224969}.btn-info:active:hover,.btn-info.active:hover,.open>.dropdown-toggle.btn-info:hover,.btn-info:active:focus,.btn-info.active:focus,.open>.dropdown-toggle.btn-info:focus,.btn-info:active.focus,.btn-info.active.focus,.open>.dropdown-toggle.btn-info.focus{color:#fff;background-color:#224969;border-color:#122637}.btn-info:active,.btn-info.active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled,.btn-info[disabled],fieldset[disabled] .btn-info,.btn-info.disabled:hover,.btn-info[disabled]:hover,fieldset[disabled] .btn-info:hover,.btn-info.disabled:focus,.btn-info[disabled]:focus,fieldset[disabled] .btn-info:focus,.btn-info.disabled.focus,.btn-info[disabled].focus,fieldset[disabled] .btn-info.focus,.btn-info.disabled:active,.btn-info[disabled]:active,fieldset[disabled] .btn-info:active,.btn-info.disabled.active,.btn-info[disabled].active,fieldset[disabled] .btn-info.active{background-color:#3776ab;border-color:#316998}.btn-info .badge{color:#3776ab;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning:focus,.btn-warning.focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active,.btn-warning.active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active:hover,.btn-warning.active:hover,.open>.dropdown-toggle.btn-warning:hover,.btn-warning:active:focus,.btn-warning.active:focus,.open>.dropdown-toggle.btn-warning:focus,.btn-warning:active.focus,.btn-warning.active.focus,.open>.dropdown-toggle.btn-warning.focus{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning:active,.btn-warning.active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-warning,.btn-warning.disabled:hover,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning:hover,.btn-warning.disabled:focus,.btn-warning[disabled]:focus,fieldset[disabled] .btn-warning:focus,.btn-warning.disabled.focus,.btn-warning[disabled].focus,fieldset[disabled] .btn-warning.focus,.btn-warning.disabled:active,.btn-warning[disabled]:active,fieldset[disabled] .btn-warning:active,.btn-warning.disabled.active,.btn-warning[disabled].active,fieldset[disabled] .btn-warning.active{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger:focus,.btn-danger.focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active,.btn-danger.active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active:hover,.btn-danger.active:hover,.open>.dropdown-toggle.btn-danger:hover,.btn-danger:active:focus,.btn-danger.active:focus,.open>.dropdown-toggle.btn-danger:focus,.btn-danger:active.focus,.btn-danger.active.focus,.open>.dropdown-toggle.btn-danger.focus{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger:active,.btn-danger.active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled,.btn-danger[disabled],fieldset[disabled] .btn-danger,.btn-danger.disabled:hover,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger:hover,.btn-danger.disabled:focus,.btn-danger[disabled]:focus,fieldset[disabled] .btn-danger:focus,.btn-danger.disabled.focus,.btn-danger[disabled].focus,fieldset[disabled] .btn-danger.focus,.btn-danger.disabled:active,.btn-danger[disabled]:active,fieldset[disabled] .btn-danger:active,.btn-danger.disabled.active,.btn-danger[disabled].active,fieldset[disabled] .btn-danger.active{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{color:#3776ab;font-weight:normal;border-radius:0}.btn-link,.btn-link:active,.btn-link.active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:hover,.btn-link:focus,.btn-link:active{border-color:transparent}.btn-link:hover,.btn-link:focus{color:#244e71;text-decoration:underline;background-color:transparent}.btn-link[disabled]:hover,fieldset[disabled] .btn-link:hover,.btn-link[disabled]:focus,fieldset[disabled] .btn-link:focus{color:#777;text-decoration:none}.btn-lg,.btn-group-lg>.btn{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-sm,.btn-group-sm>.btn{padding:5px 10px;font-size:11px;line-height:1.5;border-radius:3px}.btn-xs,.btn-group-xs>.btn{padding:1px 5px;font-size:11px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-property:height,visibility;transition-property:height,visibility;-webkit-transition-duration:.35s;transition-duration:.35s;-webkit-transition-timing-function:ease;transition-timing-function:ease}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid \9;border-right:4px solid transparent;border-left:4px solid transparent}.dropup,.dropdown{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;list-style:none;font-size:14px;text-align:left;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,0.175);box-shadow:0 6px 12px rgba(0,0,0,0.175);background-clip:padding-box}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:normal;line-height:1.428571429;color:#333;white-space:nowrap}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{text-decoration:none;color:#262626;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{color:#fff;text-decoration:none;outline:0;background-color:#82b043}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{color:#777}.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{text-decoration:none;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);cursor:not-allowed}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{left:auto;right:0}.dropdown-menu-left{left:0;right:auto}.dropdown-header{display:block;padding:3px 20px;font-size:11px;line-height:1.428571429;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;left:0;right:0;bottom:0;top:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-top:0;border-bottom:4px dashed;border-bottom:4px solid \9;content:""}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media(min-width:768px){.navbar-right .dropdown-menu{left:auto;right:0}.navbar-right .dropdown-menu-left{left:0;right:auto}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;float:left}.btn-group>.btn:hover,.btn-group-vertical>.btn:hover,.btn-group>.btn:focus,.btn-group-vertical>.btn:focus,.btn-group>.btn:active,.btn-group-vertical>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn.active{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-bottom-left-radius:0;border-top-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-left:8px;padding-right:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-left:12px;padding-right:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-bottom-left-radius:4px;border-top-right-radius:0;border-top-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-right-radius:0;border-top-left-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{float:none;display:table-cell;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle="buttons"]>.btn input[type="radio"],[data-toggle="buttons"]>.btn-group>.btn input[type="radio"],[data-toggle="buttons"]>.btn input[type="checkbox"],[data-toggle="buttons"]>.btn-group>.btn input[type="checkbox"]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*="col-"]{float:none;padding-left:0;padding-right:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn,select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:28px;padding:5px 10px;font-size:11px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:28px;line-height:28px}textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn,select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn{height:auto}.input-group-addon,.input-group-btn,.input-group .form-control{display:table-cell}.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child),.input-group .form-control:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:normal;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:11px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type="radio"],.input-group-addon input[type="checkbox"]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group-btn:last-child>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-top-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:first-child>.btn-group:not(:first-child)>.btn{border-bottom-left-radius:0;border-top-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:hover,.input-group-btn>.btn:focus,.input-group-btn>.btn:active{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{margin-bottom:0;padding-left:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:5px 10px}.nav>li>a:hover,.nav>li>a:focus{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:hover,.nav>li.disabled>a:focus{color:#777;text-decoration:none;background-color:transparent;cursor:not-allowed}.nav .open>a,.nav .open>a:hover,.nav .open>a:focus{background-color:#eee;border-color:#3776ab}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.428571429;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{color:#555;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent;cursor:default}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media(min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border:1px solid #ddd}@media(min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:hover,.nav-pills>li.active>a:focus{color:#fff;background-color:#82b043}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media(min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border:1px solid #ddd}@media(min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-right-radius:0;border-top-left-radius:0}.navbar{position:relative;min-height:40px;margin-bottom:20px;border:1px solid transparent}@media(min-width:768px){.navbar{border-radius:4px}}@media(min-width:768px){.navbar-header{float:left}}.navbar-collapse{overflow-x:visible;padding-right:15px;padding-left:15px;border-top:1px solid transparent;box-shadow:inset 0 1px 0 rgba(255,255,255,0.1);-webkit-overflow-scrolling:touch}.navbar-collapse.in{overflow-y:auto}@media(min-width:768px){.navbar-collapse{width:auto;border-top:0;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{padding-left:0;padding-right:0}}.navbar-fixed-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{max-height:340px}@media(max-device-width:480px) and (orientation:landscape){.navbar-fixed-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{max-height:200px}}.container>.navbar-header,.container-fluid>.navbar-header,.container>.navbar-collapse,.container-fluid>.navbar-collapse{margin-right:-15px;margin-left:-15px}@media(min-width:768px){.container>.navbar-header,.container-fluid>.navbar-header,.container>.navbar-collapse,.container-fluid>.navbar-collapse{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media(min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-top,.navbar-fixed-bottom{position:fixed;right:0;left:0;z-index:1030}@media(min-width:768px){.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;padding:10px 15px;font-size:18px;line-height:20px;height:40px}.navbar-brand:hover,.navbar-brand:focus{text-decoration:none}.navbar-brand>img{display:block}@media(min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;margin-right:15px;padding:9px 10px;margin-top:3px;margin-bottom:3px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media(min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media(max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;box-shadow:none}.navbar-nav .open .dropdown-menu>li>a,.navbar-nav .open .dropdown-menu .dropdown-header{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:hover,.navbar-nav .open .dropdown-menu>li>a:focus{background-image:none}}@media(min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px}}.navbar-form{margin-left:-15px;margin-right:-15px;padding:10px 15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-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);margin-top:3px;margin-bottom:3px}@media(min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn,.navbar-form .input-group .form-control{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .radio,.navbar-form .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .radio label,.navbar-form .checkbox label{padding-left:0}.navbar-form .radio input[type="radio"],.navbar-form .checkbox input[type="checkbox"]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media(max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media(min-width:768px){.navbar-form{width:auto;border:0;margin-left:0;margin-right:0;padding-top:0;padding-bottom:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-right-radius:0;border-top-left-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-right-radius:4px;border-top-left-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:3px;margin-bottom:3px}.navbar-btn.btn-sm{margin-top:6px;margin-bottom:6px}.navbar-btn.btn-xs{margin-top:9px;margin-bottom:9px}.navbar-text{margin-top:10px;margin-bottom:10px}@media(min-width:768px){.navbar-text{float:left;margin-left:15px;margin-right:15px}}@media(min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right ~ .navbar-right{margin-right:0}}.navbar-default{background-color:#fff;border-color:#eee}.navbar-default .navbar-brand{color:#aaa}.navbar-default .navbar-brand:hover,.navbar-default .navbar-brand:focus{color:#919191;background-color:transparent}.navbar-default .navbar-text{color:#000}.navbar-default .navbar-nav>li>a{color:#000}.navbar-default .navbar-nav>li>a:hover,.navbar-default .navbar-nav>li>a:focus{color:#3776ab;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:hover,.navbar-default .navbar-nav>.active>a:focus{color:#3776ab;background-color:#f7f7f7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:hover,.navbar-default .navbar-nav>.disabled>a:focus{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:hover,.navbar-default .navbar-toggle:focus{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#eee}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:hover,.navbar-default .navbar-nav>.open>a:focus{background-color:#f7f7f7;color:#3776ab}@media(max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#000}.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus{color:#3776ab;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus{color:#3776ab;background-color:#f7f7f7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#000}.navbar-default .navbar-link:hover{color:#3776ab}.navbar-default .btn-link{color:#000}.navbar-default .btn-link:hover,.navbar-default .btn-link:focus{color:#3776ab}.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:hover,.navbar-default .btn-link[disabled]:focus,fieldset[disabled] .navbar-default .btn-link:focus{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:hover,.navbar-inverse .navbar-brand:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:hover,.navbar-inverse .navbar-nav>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:hover,.navbar-inverse .navbar-nav>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:hover,.navbar-inverse .navbar-nav>.disabled>a:focus{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:hover,.navbar-inverse .navbar-toggle:focus{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:hover,.navbar-inverse .navbar-nav>.open>a:focus{background-color:#080808;color:#fff}@media(max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:hover,.navbar-inverse .btn-link:focus{color:#fff}.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:hover,.navbar-inverse .btn-link[disabled]:focus,fieldset[disabled] .navbar-inverse .btn-link:focus{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#fff;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{content:"/\00a0";padding:0 5px;color:#ccc}.breadcrumb>.active{color:#aaa}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;line-height:1.428571429;text-decoration:none;color:#3776ab;background-color:#fff;border:1px solid #ddd;margin-left:-1px}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-bottom-left-radius:4px;border-top-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-bottom-right-radius:4px;border-top-right-radius:4px}.pagination>li>a:hover,.pagination>li>span:hover,.pagination>li>a:focus,.pagination>li>span:focus{z-index:3;color:#244e71;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>span,.pagination>.active>a:hover,.pagination>.active>span:hover,.pagination>.active>a:focus,.pagination>.active>span:focus{z-index:2;color:#fff;background-color:#82b043;border-color:#82b043;cursor:default}.pagination>.disabled>span,.pagination>.disabled>span:hover,.pagination>.disabled>span:focus,.pagination>.disabled>a,.pagination>.disabled>a:hover,.pagination>.disabled>a:focus{color:#777;background-color:#fff;border-color:#ddd;cursor:not-allowed}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-bottom-right-radius:6px;border-top-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:11px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-bottom-left-radius:3px;border-top-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-bottom-right-radius:3px;border-top-right-radius:3px}.pager{padding-left:0;margin:20px 0;list-style:none;text-align:center}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:hover,.pager li>a:focus{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:hover,.pager .disabled>a:focus,.pager .disabled>span{color:#777;background-color:#fff;cursor:not-allowed}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:bold;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:hover,a.label:focus{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:hover,.label-default[href]:focus{background-color:#5e5e5e}.label-primary{background-color:#82b043}.label-primary[href]:hover,.label-primary[href]:focus{background-color:#678b35}.label-success{background-color:#82b043}.label-success[href]:hover,.label-success[href]:focus{background-color:#678b35}.label-info{background-color:#3776ab}.label-info[href]:hover,.label-info[href]:focus{background-color:#2b5b84}.label-warning{background-color:#f0ad4e}.label-warning[href]:hover,.label-warning[href]:focus{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:hover,.label-danger[href]:focus{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:11px;font-weight:bold;color:#fff;line-height:1;vertical-align:middle;white-space:nowrap;text-align:center;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-xs .badge,.btn-group-xs>.btn .badge{top:0;padding:1px 5px}a.badge:hover,a.badge:focus{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#3776ab;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#ffd343}.jumbotron h1,.jumbotron .h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#ffc710}.container .jumbotron,.container-fluid .jumbotron{border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-left:60px;padding-right:60px}.jumbotron h1,.jumbotron .h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.428571429;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail>img,.thumbnail a>img{margin-left:auto;margin-right:auto}a.thumbnail:hover,a.thumbnail:focus,a.thumbnail.active{border-color:#3776ab}.thumbnail .caption{padding:9px;color:#000}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:bold}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{background-color:#e2eed1;border-color:#dce7bf;color:#678b35}.alert-success hr{border-top-color:#d3e0ac}.alert-success .alert-link{color:#4b6627}.alert-info{background-color:#c2d9ec;border-color:#a7d2e3;color:#3776ab}.alert-info hr{border-top-color:#94c8dd}.alert-info .alert-link{color:#2b5b84}.alert-warning{background-color:#fcf8e3;border-color:#faebcc;color:#c09853}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#a47e3c}.alert-danger{background-color:#f2dede;border-color:#ebccd1;color:#b94a48}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#953b39}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{overflow:hidden;height:20px;margin-bottom:20px;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);box-shadow:inset 0 1px 2px rgba(0,0,0,0.1)}.progress-bar{float:left;width:0;height:100%;font-size:11px;line-height:20px;color:#fff;text-align:center;background-color:#82b043;-webkit-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-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-striped .progress-bar,.progress-bar-striped{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:-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);background-size:40px 40px}.progress.active .progress-bar,.progress-bar.active{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#82b043}.progress-striped .progress-bar-success{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:-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-bar-info{background-color:#3776ab}.progress-striped .progress-bar-info{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:-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-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{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:-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-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{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:-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)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{zoom:1;overflow:hidden}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-left,.media-right,.media-body{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{margin-bottom:20px;padding-left:0}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-right-radius:4px;border-top-left-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:hover,button.list-group-item:hover,a.list-group-item:focus,button.list-group-item:focus{text-decoration:none;color:#555;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:hover,.list-group-item.disabled:focus{background-color:#eee;color:#777;cursor:not-allowed}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{z-index:2;color:#fff;background-color:#82b043;border-color:#82b043}.list-group-item.active .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>.small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:hover .list-group-item-text,.list-group-item.active:focus .list-group-item-text{color:#e2eed1}.list-group-item-success{color:#678b35;background-color:#e2eed1}a.list-group-item-success,button.list-group-item-success{color:#678b35}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:hover,button.list-group-item-success:hover,a.list-group-item-success:focus,button.list-group-item-success:focus{color:#678b35;background-color:#d6e7bf}a.list-group-item-success.active,button.list-group-item-success.active,a.list-group-item-success.active:hover,button.list-group-item-success.active:hover,a.list-group-item-success.active:focus,button.list-group-item-success.active:focus{color:#fff;background-color:#678b35;border-color:#678b35}.list-group-item-info{color:#3776ab;background-color:#c2d9ec}a.list-group-item-info,button.list-group-item-info{color:#3776ab}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:hover,button.list-group-item-info:hover,a.list-group-item-info:focus,button.list-group-item-info:focus{color:#3776ab;background-color:#afcde5}a.list-group-item-info.active,button.list-group-item-info.active,a.list-group-item-info.active:hover,button.list-group-item-info.active:hover,a.list-group-item-info.active:focus,button.list-group-item-info.active:focus{color:#fff;background-color:#3776ab;border-color:#3776ab}.list-group-item-warning{color:#c09853;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#c09853}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:hover,button.list-group-item-warning:hover,a.list-group-item-warning:focus,button.list-group-item-warning:focus{color:#c09853;background-color:#faf2cc}a.list-group-item-warning.active,button.list-group-item-warning.active,a.list-group-item-warning.active:hover,button.list-group-item-warning.active:hover,a.list-group-item-warning.active:focus,button.list-group-item-warning.active:focus{color:#fff;background-color:#c09853;border-color:#c09853}.list-group-item-danger{color:#b94a48;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#b94a48}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:hover,button.list-group-item-danger:hover,a.list-group-item-danger:focus,button.list-group-item-danger:focus{color:#b94a48;background-color:#ebcccc}a.list-group-item-danger.active,button.list-group-item-danger.active,a.list-group-item-danger.active:hover,button.list-group-item-danger.active:hover,a.list-group-item-danger.active:focus,button.list-group-item-danger.active:focus{color:#fff;background-color:#b94a48;border-color:#b94a48}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,0.05);box-shadow:0 1px 1px rgba(0,0,0,0.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-right-radius:3px;border-top-left-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>a,.panel-title>small,.panel-title>.small,.panel-title>small>a,.panel-title>.small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-right-radius:3px;border-top-left-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-right-radius:0;border-top-left-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.table,.panel>.table-responsive>.table,.panel>.panel-collapse>.table{margin-bottom:0}.panel>.table caption,.panel>.table-responsive>.table caption,.panel>.panel-collapse>.table caption{padding-left:15px;padding-right:15px}.panel>.table:first-child,.panel>.table-responsive:first-child>.table:first-child{border-top-right-radius:3px;border-top-left-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table:last-child,.panel>.table-responsive:last-child>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-left-radius:3px;border-bottom-right-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child th,.panel>.table>tbody:first-child>tr:first-child td{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{border:0;margin-bottom:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.panel-body,.panel-group .panel-heading+.panel-collapse>.list-group{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#4d4d4d;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#4d4d4d}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#82b043}.panel-primary>.panel-heading{color:#fff;background-color:#82b043;border-color:#82b043}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#82b043}.panel-primary>.panel-heading .badge{color:#82b043;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#82b043}.panel-success{border-color:#dce7bf}.panel-success>.panel-heading{color:#678b35;background-color:#e2eed1;border-color:#dce7bf}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#dce7bf}.panel-success>.panel-heading .badge{color:#e2eed1;background-color:#678b35}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#dce7bf}.panel-info{border-color:#a7d2e3}.panel-info>.panel-heading{color:#3776ab;background-color:#c2d9ec;border-color:#a7d2e3}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#a7d2e3}.panel-info>.panel-heading .badge{color:#c2d9ec;background-color:#3776ab}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#a7d2e3}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#c09853;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#c09853}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#b94a48;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#b94a48}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive iframe,.embed-responsive embed,.embed-responsive object,.embed-responsive video{position:absolute;top:0;left:0;bottom:0;height:100%;width:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f6f6f6;border:1px solid #e9f1f8;border-radius:4px;-webkit-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)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:bold;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.2;filter:alpha(opacity=20)}.close:hover,.close:focus{color:#000;text-decoration:none;cursor:pointer;opacity:.5;filter:alpha(opacity=50)}button.close{padding:0;cursor:pointer;background:transparent;border:0;-webkit-appearance:none}.modal-open{overflow:hidden}.modal{display:none;overflow:hidden;position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%);-webkit-transition:-webkit-transform .3s ease-out;-moz-transition:-moz-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;border:1px solid #999;border:1px solid rgba(0,0,0,0.2);border-radius:6px;-webkit-box-shadow:0 3px 9px rgba(0,0,0,0.5);box-shadow:0 3px 9px rgba(0,0,0,0.5);background-clip:padding-box;outline:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{opacity:0;filter:alpha(opacity=0)}.modal-backdrop.in{opacity:.5;filter:alpha(opacity=50)}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5;min-height:16.428571429px}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.428571429}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-left:5px;margin-bottom:0}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media(min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,0.5);box-shadow:0 5px 15px rgba(0,0,0,0.5)}.modal-sm{width:300px}}@media(min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:Verdana,sans-serif;font-style:normal;font-weight:normal;letter-spacing:normal;line-break:auto;line-height:1.428571429;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:11px;opacity:0;filter:alpha(opacity=0)}.tooltip.in{opacity:.9;filter:alpha(opacity=90)}.tooltip.top{margin-top:-3px;padding:5px 0}.tooltip.right{margin-left:3px;padding:0 5px}.tooltip.bottom{margin-top:3px;padding:5px 0}.tooltip.left{margin-left:-3px;padding:0 5px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{bottom:0;right:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:Verdana,sans-serif;font-style:normal;font-weight:normal;letter-spacing:normal;line-break:auto;line-height:1.428571429;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:14px;background-color:#fff;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,0.2);box-shadow:0 5px 10px rgba(0,0,0,0.2)}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{margin:0;padding:8px 14px;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{border-width:10px;content:""}.popover.top>.arrow{left:50%;margin-left:-11px;border-bottom-width:0;border-top-color:#999;border-top-color:rgba(0,0,0,0.25);bottom:-11px}.popover.top>.arrow:after{content:" ";bottom:1px;margin-left:-10px;border-bottom-width:0;border-top-color:#fff}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-left-width:0;border-right-color:#999;border-right-color:rgba(0,0,0,0.25)}.popover.right>.arrow:after{content:" ";left:1px;bottom:-10px;border-left-width:0;border-right-color:#fff}.popover.bottom>.arrow{left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,0.25);top:-11px}.popover.bottom>.arrow:after{content:" ";top:1px;margin-left:-10px;border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,0.25)}.popover.left>.arrow:after{content:" ";right:1px;border-right-width:0;border-left-color:#fff;bottom:-10px}.carousel{position:relative}.carousel-inner{position:relative;overflow:hidden;width:100%}.carousel-inner>.item{display:none;position:relative;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>img,.carousel-inner>.item>a>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-moz-transition:-moz-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;-moz-perspective:1000px;perspective:1000px}.carousel-inner>.item.next,.carousel-inner>.item.active.right{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);left:0}.carousel-inner>.item.prev,.carousel-inner>.item.active.left{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);left:0}.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right,.carousel-inner>.item.active{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);left:0}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;left:0;bottom:0;width:15%;opacity:.5;filter:alpha(opacity=50);font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6)}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,0.5) 0,rgba(0,0,0,0.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,0.5) 0,rgba(0,0,0,0.0001) 100%);background-image:linear-gradient(to right,rgba(0,0,0,0.5) 0,rgba(0,0,0,0.0001) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000',endColorstr='#00000000',GradientType=1)}.carousel-control.right{left:auto;right:0;background-image:-webkit-linear-gradient(left,rgba(0,0,0,0.0001) 0,rgba(0,0,0,0.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,0.0001) 0,rgba(0,0,0,0.5) 100%);background-image:linear-gradient(to right,rgba(0,0,0,0.0001) 0,rgba(0,0,0,0.5) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000',endColorstr='#80000000',GradientType=1)}.carousel-control:hover,.carousel-control:focus{outline:0;color:#fff;text-decoration:none;opacity:.9;filter:alpha(opacity=90)}.carousel-control .icon-prev,.carousel-control .icon-next,.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right{position:absolute;top:50%;margin-top:-10px;z-index:5;display:inline-block}.carousel-control .icon-prev,.carousel-control .glyphicon-chevron-left{left:50%;margin-left:-10px}.carousel-control .icon-next,.carousel-control .glyphicon-chevron-right{right:50%;margin-right:-10px}.carousel-control .icon-prev,.carousel-control .icon-next{width:20px;height:20px;line-height:1;font-family:serif}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;margin-left:-30%;padding-left:0;list-style:none;text-align:center}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;border:1px solid #fff;border-radius:10px;cursor:pointer;background-color:#000 \9;background-color:rgba(0,0,0,0)}.carousel-indicators .active{margin:0;width:12px;height:12px;background-color:#fff}.carousel-caption{position:absolute;left:15%;right:15%;bottom:20px;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-prev,.carousel-control .icon-next{width:30px;height:30px;margin-top:-15px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-15px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-15px}.carousel-caption{left:20%;right:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.clearfix:before,.clearfix:after,.dl-horizontal dd:before,.dl-horizontal dd:after,.container:before,.container:after,.container-fluid:before,.container-fluid:after,.row:before,.row:after,.form-horizontal .form-group:before,.form-horizontal .form-group:after,.btn-toolbar:before,.btn-toolbar:after,.btn-group-vertical>.btn-group:before,.btn-group-vertical>.btn-group:after,.nav:before,.nav:after,.navbar:before,.navbar:after,.navbar-header:before,.navbar-header:after,.navbar-collapse:before,.navbar-collapse:after,.pager:before,.pager:after,.panel-body:before,.panel-body:after,.modal-footer:before,.modal-footer:after{content:" ";display:table}.clearfix:after,.dl-horizontal dd:after,.container:after,.container-fluid:after,.row:after,.form-horizontal .form-group:after,.btn-toolbar:after,.btn-group-vertical>.btn-group:after,.nav:after,.navbar:after,.navbar-header:after,.navbar-collapse:after,.pager:after,.panel-body:after,.modal-footer:after{clear:both}.center-block{display:block;margin-left:auto;margin-right:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-xs,.visible-sm,.visible-md,.visible-lg{display:none!important}.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block{display:none!important}@media(max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}th.visible-xs,td.visible-xs{display:table-cell!important}}@media(max-width:767px){.visible-xs-block{display:block!important}}@media(max-width:767px){.visible-xs-inline{display:inline!important}}@media(max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media(min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}th.visible-sm,td.visible-sm{display:table-cell!important}}@media(min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media(min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media(min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media(min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}th.visible-md,td.visible-md{display:table-cell!important}}@media(min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media(min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media(min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media(min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}th.visible-lg,td.visible-lg{display:table-cell!important}}@media(min-width:1200px){.visible-lg-block{display:block!important}}@media(min-width:1200px){.visible-lg-inline{display:inline!important}}@media(min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media(max-width:767px){.hidden-xs{display:none!important}}@media(min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media(min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media(min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}th.visible-print,td.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}}
\ No newline at end of file
diff --git a/pythonz/apps/static/css/pythonz.css b/pythonz/apps/static/css/pythonz.css
new file mode 100644
index 00000000..d53ac124
--- /dev/null
+++ b/pythonz/apps/static/css/pythonz.css
@@ -0,0 +1 @@
+.block{display:inline-block;}.of__hidden{overflow:hidden;}.cl__blue{color:#3776ab;}.cl__yellow{color:#ffd343;}.cl__green{color:#82b043;}.cl__gray{color:#888888;}.alpha-index-bar a.current{text-decoration:underline;}.alpha-index-bar li{display:inline-block;margin-right:10px;}.alpha-index-list{padding-left:20px;}.container{border-radius:10px;}.breadcrumbs ol{border-radius:10px;}.lead{margin-bottom:20px;}.footer{padding-top:20px;font-size:14px;}.footer .menu{padding-left:0;margin-left:0;}.footer .menu li{display:inline-block;margin-right:10px;}.footer address i,.footer a{color:#cccccc;}.page-header{border-bottom:none;}.page-header .icon_entity{margin-right:10px;}.page-header h1{word-break:break-word;font-size:1.8em;text-transform:uppercase;color:#ffffff;background-color:#888888;letter-spacing:2px;padding:10px;}.header{border-bottom:#d4d4d4 solid 2px;}.navbar{margin-bottom:0;}.navbar input{margin-top:4px;height:25px;max-width:150px;min-width:150px;font-size:10px;}.navbar .navbar-brand img{margin:5px;}.navbar-brand{padding:6px 6px 6px 20px;}.control-label{font-weight:normal;color:#666666;}.well a{color:#666666;text-decoration:underline;}.well a.btn{text-decoration:none;}.badge-primary a,.badge-info a,.badge-danger a{color:#ffffff !important;}address .fa{vertical-align:middle;color:#000000;}address .fa-2x{vertical-align:middle;color:#82b043;}a{color:#3776ab;}kbd{background-color:#4f90c6;}code{color:#000000 !important;}body{background:#efefef;}h6{padding-bottom:10px;font-weight:bold;margin-bottom:20px;color:#4f90c6;border-bottom:1px #3776ab dashed;}input[type=checkbox]{display:inline-block;width:20px;vertical-align:bottom;}.b-forgetmenot_size_l{height:28px !important;}#box-topvoted{background-color:#fbfbfb;border-radius:10px;}#box-topvoted .listing-item{min-width:50px;}.ref-linked{word-spacing:6px;}nav .submenu{border-left:10px #82b043 solid;border-right:10px #82b043 solid;}nav .submenu li{padding-left:10px;padding-right:10px;}.body{background-color:#ffffff;padding-top:20px;padding-bottom:20px;}.mod__has_tooltip{cursor:help;}.icon_entity{font-size:40px;float:left;color:#3776ab;}.form_table td{vertical-align:top;}.panel-float{min-width:250px;max-width:300px;padding:10px;}.userlist_item{margin:2px;}.gist{overflow:auto;}.gist pre{font-size:12px !important;}.discussions{list-style-type:none;padding:0;}.discussions .well{background-color:#ffffff;}.discussions .discussion{padding:10px;}.discussions .discussion:hover{background-color:#f9fcf6;}#box-tags .big .title{font-size:20px;min-width:250px;max-width:250px;}#box-tags .big .categories_box{margin-bottom:40px;}#box-tags .big .list_entry{margin-right:20px;}#box-tags ul{list-style-type:none;display:inline;padding:0;vertical-align:middle;}#box-tags .categories_box{margin-bottom:10px;}#box-tags .btn_remove{margin-right:10px;}#box-tags form{display:inline;}#box-tags .title{color:#888888;min-width:55px;max-width:55px;font-size:10px;display:inline;float:left;margin-right:10px;}#box-tags .choice{margin-right:10px;font-size:12px;color:#3776ab;cursor:pointer;}#box-tags .editor{margin:10px 0 10px 63px;padding-bottom:10px;border-bottom:1px solid #eeeeee;}#box-tags li{margin:0;padding:0;display:inline;}#box-tags input{font-size:12px;}.zen .eng{line-height:1.3em;color:#666666;}.zen .small{text-align:right;color:#666666;}.img-cover{text-align:center;}.img-cover .icon_entity{padding:20px;margin-right:10px;color:#bbbbbb;min-width:100px;}.img-cover a{text-align:center;}.faded{opacity:0.1;}.faded:hover{opacity:1;}.listing-item{min-width:250px;}.listing-item .img-thumbnail{margin-right:10px;}#box-index .card{padding:0 0 0 0;border:none !important;}#box-index img{border:0;}#box-index .feature{background-color:#1a1a1a;}#box-index .ruler{height:3px;background-color:#82b043;}#box-index li{color:#888888;}#box-index h4{white-space:nowrap;margin:0 10px 20px 10px;}#box-index h4 a{color:#666666;}.subtitle{color:#ffffff;background-color:#888888;letter-spacing:2px;padding:10px;display:inline-block;font-size:1.2em;}.ya-share2{display:inline-block;vertical-align:middle;}.navbar-light .navbar-nav .nav-link{color:#4f4f4f !important;}.navbar-light .navbar-nav .active>.nav-link{color:#4f4f4f !important;font-weight:bold !important;}.dropdown-item{color:#4f4f4f !important;}.breadcrumb-item.active{color:#4f4f4f !important;}#rss{color:#0f446e !important;}.page-item.active .page-link{background-color:#0050a7 !important;color:white !important;}.page-link{color:#0050a7 !important;}.badge-success{background-color:#155d25 !important;}.badge-info,.badge-primary{background-color:#0e5e6b !important;}.cl__gray{color:#555555;}.pep-href{color:#254d6f !important;}#box-tags .title{color:#555555 !important;}
\ No newline at end of file
diff --git a/data/static_src/fonts/foundation-icons.eot b/pythonz/apps/static/fonts/foundation-icons.eot
similarity index 100%
rename from data/static_src/fonts/foundation-icons.eot
rename to pythonz/apps/static/fonts/foundation-icons.eot
diff --git a/data/static_src/fonts/foundation-icons.svg b/pythonz/apps/static/fonts/foundation-icons.svg
similarity index 100%
rename from data/static_src/fonts/foundation-icons.svg
rename to pythonz/apps/static/fonts/foundation-icons.svg
diff --git a/data/static_src/fonts/foundation-icons.ttf b/pythonz/apps/static/fonts/foundation-icons.ttf
similarity index 100%
rename from data/static_src/fonts/foundation-icons.ttf
rename to pythonz/apps/static/fonts/foundation-icons.ttf
diff --git a/data/static_src/fonts/foundation-icons.woff b/pythonz/apps/static/fonts/foundation-icons.woff
similarity index 100%
rename from data/static_src/fonts/foundation-icons.woff
rename to pythonz/apps/static/fonts/foundation-icons.woff
diff --git a/data/static_src/fonts/glyphicons-halflings-regular.eot b/pythonz/apps/static/fonts/glyphicons-halflings-regular.eot
similarity index 100%
rename from data/static_src/fonts/glyphicons-halflings-regular.eot
rename to pythonz/apps/static/fonts/glyphicons-halflings-regular.eot
diff --git a/data/static_src/fonts/glyphicons-halflings-regular.svg b/pythonz/apps/static/fonts/glyphicons-halflings-regular.svg
similarity index 100%
rename from data/static_src/fonts/glyphicons-halflings-regular.svg
rename to pythonz/apps/static/fonts/glyphicons-halflings-regular.svg
diff --git a/data/static_src/fonts/glyphicons-halflings-regular.ttf b/pythonz/apps/static/fonts/glyphicons-halflings-regular.ttf
similarity index 100%
rename from data/static_src/fonts/glyphicons-halflings-regular.ttf
rename to pythonz/apps/static/fonts/glyphicons-halflings-regular.ttf
diff --git a/data/static_src/fonts/glyphicons-halflings-regular.woff b/pythonz/apps/static/fonts/glyphicons-halflings-regular.woff
similarity index 100%
rename from data/static_src/fonts/glyphicons-halflings-regular.woff
rename to pythonz/apps/static/fonts/glyphicons-halflings-regular.woff
diff --git a/data/static_src/fonts/glyphicons-halflings-regular.woff2 b/pythonz/apps/static/fonts/glyphicons-halflings-regular.woff2
similarity index 100%
rename from data/static_src/fonts/glyphicons-halflings-regular.woff2
rename to pythonz/apps/static/fonts/glyphicons-halflings-regular.woff2
diff --git a/data/static_src/img/favicon.gif b/pythonz/apps/static/img/favicon.gif
similarity index 100%
rename from data/static_src/img/favicon.gif
rename to pythonz/apps/static/img/favicon.gif
diff --git a/pythonz/apps/static/img/realm_logos/pythonz.png b/pythonz/apps/static/img/realm_logos/pythonz.png
new file mode 100644
index 00000000..266fc692
Binary files /dev/null and b/pythonz/apps/static/img/realm_logos/pythonz.png differ
diff --git a/pythonz/apps/static/img/realm_logos/pythonz_articles.png b/pythonz/apps/static/img/realm_logos/pythonz_articles.png
new file mode 100644
index 00000000..1096aa2c
Binary files /dev/null and b/pythonz/apps/static/img/realm_logos/pythonz_articles.png differ
diff --git a/pythonz/apps/static/img/realm_logos/pythonz_books.png b/pythonz/apps/static/img/realm_logos/pythonz_books.png
new file mode 100644
index 00000000..32041500
Binary files /dev/null and b/pythonz/apps/static/img/realm_logos/pythonz_books.png differ
diff --git a/pythonz/apps/static/img/realm_logos/pythonz_bot.png b/pythonz/apps/static/img/realm_logos/pythonz_bot.png
new file mode 100644
index 00000000..6b67a79f
Binary files /dev/null and b/pythonz/apps/static/img/realm_logos/pythonz_bot.png differ
diff --git a/pythonz/apps/static/img/realm_logos/pythonz_communities.png b/pythonz/apps/static/img/realm_logos/pythonz_communities.png
new file mode 100644
index 00000000..d053f569
Binary files /dev/null and b/pythonz/apps/static/img/realm_logos/pythonz_communities.png differ
diff --git a/pythonz/apps/static/img/realm_logos/pythonz_discussions.png b/pythonz/apps/static/img/realm_logos/pythonz_discussions.png
new file mode 100644
index 00000000..3f8c9959
Binary files /dev/null and b/pythonz/apps/static/img/realm_logos/pythonz_discussions.png differ
diff --git a/pythonz/apps/static/img/realm_logos/pythonz_places.png b/pythonz/apps/static/img/realm_logos/pythonz_places.png
new file mode 100644
index 00000000..7856bb3e
Binary files /dev/null and b/pythonz/apps/static/img/realm_logos/pythonz_places.png differ
diff --git a/pythonz/apps/static/img/realm_logos/pythonz_references.png b/pythonz/apps/static/img/realm_logos/pythonz_references.png
new file mode 100644
index 00000000..62e9c97b
Binary files /dev/null and b/pythonz/apps/static/img/realm_logos/pythonz_references.png differ
diff --git a/pythonz/apps/static/img/realm_logos/pythonz_vacancies.png b/pythonz/apps/static/img/realm_logos/pythonz_vacancies.png
new file mode 100644
index 00000000..53fbee89
Binary files /dev/null and b/pythonz/apps/static/img/realm_logos/pythonz_vacancies.png differ
diff --git a/pythonz/apps/static/img/realm_logos/pythonz_videos.png b/pythonz/apps/static/img/realm_logos/pythonz_videos.png
new file mode 100644
index 00000000..a7b36e6e
Binary files /dev/null and b/pythonz/apps/static/img/realm_logos/pythonz_videos.png differ
diff --git a/pythonz/apps/static/js/geopattern-1.2.3.min.js b/pythonz/apps/static/js/geopattern-1.2.3.min.js
new file mode 100644
index 00000000..50332f6f
--- /dev/null
+++ b/pythonz/apps/static/js/geopattern-1.2.3.min.js
@@ -0,0 +1 @@
+!function(t){if("object"==typeof exports)module.exports=t();else if("function"==typeof define&&define.amd)define(t);else{var r;"undefined"!=typeof window?r=window:"undefined"!=typeof global?r=global:"undefined"!=typeof self&&(r=self),r.GeoPattern=t()}}(function(){return function t(r,s,e){function i(n,a){if(!s[n]){if(!r[n]){var h="function"==typeof require&&require;if(!a&&h)return h(n,!0);if(o)return o(n,!0);throw new Error("Cannot find module '"+n+"'")}var l=s[n]={exports:{}};r[n][0].call(l.exports,function(t){var s=r[n][1][t];return i(s?s:t)},l,l.exports,t,r,s,e)}return s[n].exports}for(var o="function"==typeof require&&require,n=0;n.5?l/(2-n-a):l/(n+a),n){case r:i=(s-e)/l+(e>s?6:0);break;case s:i=(e-r)/l+2;break;case e:i=(r-s)/l+4}i/=6}return{h:i,s:o,l:h}}function o(t){function r(t,r,s){return 0>s&&(s+=1),s>1&&(s-=1),1/6>s?t+6*(r-t)*s:.5>s?r:2/3>s?t+(r-t)*(2/3-s)*6:t}var s,e,i,o=t.h,n=t.s,a=t.l;if(0===n)s=e=i=a;else{var h=.5>a?a*(1+n):a+n-a*n,l=2*a-h;s=r(l,h,o+1/3),e=r(l,h,o),i=r(l,h,o-1/3)}return{r:Math.round(255*s),g:Math.round(255*e),b:Math.round(255*i)}}r.exports={hex2rgb:s,rgb2hex:e,rgb2hsl:i,hsl2rgb:o,rgb2rgbString:function(t){return"rgb("+[t.r,t.g,t.b].join(",")+")"}}},{}],3:[function(t,r){(function(s){"use strict";function e(t,r,s){return parseInt(t.substr(r,s||1),16)}function i(t,r,s,e,i){var o=parseFloat(t),n=s-r,a=i-e;return(o-r)*a/n+e}function o(t){return t%2===0?C:j}function n(t){return i(t,0,15,M,W)}function a(t){var r=t,s=r/2,e=Math.sin(60*Math.PI/180)*r;return[0,e,s,0,s+r,0,2*r,e,s+r,2*e,s,2*e,0,e].join(",")}function h(t,r){var s=.66*r;return[[0,0,t/2,r-s,t/2,r,0,s,0,0],[t/2,r-s,t,0,t,s,t/2,r,t/2,r-s]].map(function(t){return t.join(",")})}function l(t){return[[t,0,t,3*t],[0,t,3*t,t]]}function c(t){var r=t,s=.33*r;return[s,0,r-s,0,r,s,r,r-s,r-s,r,s,r,0,r-s,0,s,s,0].join(",")}function f(t,r){var s=t/2;return[s,0,t,r,0,r,s,0].join(",")}function u(t,r){return[t/2,0,t,r/2,t/2,r,0,r/2].join(",")}function p(t){return[0,0,t,t,0,t,0,0].join(",")}function g(t,r,s,e,i){var a=p(e),h=n(i[0]),l=o(i[0]),c={stroke:S,"stroke-opacity":A,"fill-opacity":h,fill:l};t.polyline(a,c).transform({translate:[r+e,s],scale:[-1,1]}),t.polyline(a,c).transform({translate:[r+e,s+2*e],scale:[1,-1]}),h=n(i[1]),l=o(i[1]),c={stroke:S,"stroke-opacity":A,"fill-opacity":h,fill:l},t.polyline(a,c).transform({translate:[r+e,s+2*e],scale:[-1,-1]}),t.polyline(a,c).transform({translate:[r+e,s],scale:[1,1]})}function v(t,r,s,e,i){var a=n(i),h=o(i),l=p(e),c={stroke:S,"stroke-opacity":A,"fill-opacity":a,fill:h};t.polyline(l,c).transform({translate:[r,s+e],scale:[1,-1]}),t.polyline(l,c).transform({translate:[r+2*e,s+e],scale:[-1,-1]}),t.polyline(l,c).transform({translate:[r,s+e],scale:[1,1]}),t.polyline(l,c).transform({translate:[r+2*e,s+e],scale:[-1,1]})}function y(t,r){var s=t/2;return[0,0,r,s,0,t,0,0].join(",")}var d=t("extend"),b=t("./color"),m=t("./sha1"),k=t("./svg"),x={baseColor:"#933c3c"},w=["octogons","overlappingCircles","plusSigns","xes","sineWaves","hexagons","overlappingRings","plaid","triangles","squares","concentricCircles","diamonds","tessellation","nestedSquares","mosaicSquares","chevrons"],j="#222",C="#ddd",S="#000",A=.02,M=.02,W=.15,H=r.exports=function(t,r){return this.opts=d({},x,r),this.hash=r.hash||m(t),this.svg=new k,this.generateBackground(),this.generatePattern(),this};H.prototype.toSvg=function(){return this.svg.toString()},H.prototype.toString=function(){return this.toSvg()},H.prototype.toBase64=function(){var t,r=this.toSvg();return t="undefined"!=typeof window&&"function"==typeof window.btoa?window.btoa(r):new s(r).toString("base64")},H.prototype.toDataUri=function(){return"data:image/svg+xml;base64,"+this.toBase64()},H.prototype.toDataUrl=function(){return'url("'+this.toDataUri()+'")'},H.prototype.generateBackground=function(){var t,r,s,o;this.opts.color?s=b.hex2rgb(this.opts.color):(r=i(e(this.hash,14,3),0,4095,0,359),o=e(this.hash,17),t=b.rgb2hsl(b.hex2rgb(this.opts.baseColor)),t.h=(360*t.h-r+360)%360/360,t.s=o%2===0?Math.min(1,(100*t.s+o)/100):Math.max(0,(100*t.s-o)/100),s=b.hsl2rgb(t)),this.color=b.rgb2hex(s),this.svg.rect(0,0,"100%","100%",{fill:b.rgb2rgbString(s)})},H.prototype.generatePattern=function(){var t=this.opts.generator;if(t){if(w.indexOf(t)<0)throw new Error("The generator "+t+" does not exist.")}else t=w[e(this.hash,20)];return this["geo"+t.slice(0,1).toUpperCase()+t.slice(1)]()},H.prototype.geoHexagons=function(){var t,r,s,h,l,c,f,u,p=e(this.hash,0),g=i(p,0,15,8,60),v=g*Math.sqrt(3),y=2*g,d=a(g);for(this.svg.setWidth(3*y+3*g),this.svg.setHeight(6*v),s=0,u=0;6>u;u++)for(f=0;6>f;f++)c=e(this.hash,s),t=f%2===0?u*v:u*v+v/2,h=n(c),r=o(c),l={fill:r,"fill-opacity":h,stroke:S,"stroke-opacity":A},this.svg.polyline(d,l).transform({translate:[f*g*1.5-y/2,t-v/2]}),0===f&&this.svg.polyline(d,l).transform({translate:[6*g*1.5-y/2,t-v/2]}),0===u&&(t=f%2===0?6*v:6*v+v/2,this.svg.polyline(d,l).transform({translate:[f*g*1.5-y/2,t-v/2]})),0===f&&0===u&&this.svg.polyline(d,l).transform({translate:[6*g*1.5-y/2,5*v+v/2]}),s++},H.prototype.geoSineWaves=function(){var t,r,s,a,h,l,c,f=Math.floor(i(e(this.hash,0),0,15,100,400)),u=Math.floor(i(e(this.hash,1),0,15,30,100)),p=Math.floor(i(e(this.hash,2),0,15,3,30));for(this.svg.setWidth(f),this.svg.setHeight(36*p),r=0;36>r;r++)l=e(this.hash,r),s=n(l),t=o(l),c=f/4*.7,h={fill:"none",stroke:t,opacity:s,"stroke-width":""+p+"px"},a="M0 "+u+" C "+c+" 0, "+(f/2-c)+" 0, "+f/2+" "+u+" S "+(f-c)+" "+2*u+", "+f+" "+u+" S "+(1.5*f-c)+" 0, "+1.5*f+", "+u,this.svg.path(a,h).transform({translate:[-f/4,p*r-1.5*u]}),this.svg.path(a,h).transform({translate:[-f/4,p*r-1.5*u+36*p]})},H.prototype.geoChevrons=function(){var t,r,s,a,l,c,f,u=i(e(this.hash,0),0,15,30,80),p=i(e(this.hash,0),0,15,30,80),g=h(u,p);for(this.svg.setWidth(6*u),this.svg.setHeight(6*p*.66),r=0,f=0;6>f;f++)for(c=0;6>c;c++)l=e(this.hash,r),s=n(l),t=o(l),a={stroke:S,"stroke-opacity":A,fill:t,"fill-opacity":s,"stroke-width":1},this.svg.group(a).transform({translate:[c*u,f*p*.66-p/2]}).polyline(g).end(),0===f&&this.svg.group(a).transform({translate:[c*u,6*p*.66-p/2]}).polyline(g).end(),r+=1},H.prototype.geoPlusSigns=function(){var t,r,s,a,h,c,f,u,p=i(e(this.hash,0),0,15,10,25),g=3*p,v=l(p);for(this.svg.setWidth(12*p),this.svg.setHeight(12*p),s=0,u=0;6>u;u++)for(f=0;6>f;f++)c=e(this.hash,s),a=n(c),r=o(c),t=u%2===0?0:1,h={fill:r,stroke:S,"stroke-opacity":A,"fill-opacity":a},this.svg.group(h).transform({translate:[f*g-f*p+t*p-p,u*g-u*p-g/2]}).rect(v).end(),0===f&&this.svg.group(h).transform({translate:[4*g-f*p+t*p-p,u*g-u*p-g/2]}).rect(v).end(),0===u&&this.svg.group(h).transform({translate:[f*g-f*p+t*p-p,4*g-u*p-g/2]}).rect(v).end(),0===f&&0===u&&this.svg.group(h).transform({translate:[4*g-f*p+t*p-p,4*g-u*p-g/2]}).rect(v).end(),s++},H.prototype.geoXes=function(){var t,r,s,a,h,c,f,u,p=i(e(this.hash,0),0,15,10,25),g=l(p),v=3*p*.943;for(this.svg.setWidth(3*v),this.svg.setHeight(3*v),s=0,u=0;6>u;u++)for(f=0;6>f;f++)c=e(this.hash,s),a=n(c),t=f%2===0?u*v-.5*v:u*v-.5*v+v/4,r=o(c),h={fill:r,opacity:a},this.svg.group(h).transform({translate:[f*v/2-v/2,t-u*v/2],rotate:[45,v/2,v/2]}).rect(g).end(),0===f&&this.svg.group(h).transform({translate:[6*v/2-v/2,t-u*v/2],rotate:[45,v/2,v/2]}).rect(g).end(),0===u&&(t=f%2===0?6*v-v/2:6*v-v/2+v/4,this.svg.group(h).transform({translate:[f*v/2-v/2,t-6*v/2],rotate:[45,v/2,v/2]}).rect(g).end()),5===u&&this.svg.group(h).transform({translate:[f*v/2-v/2,t-11*v/2],rotate:[45,v/2,v/2]}).rect(g).end(),0===f&&0===u&&this.svg.group(h).transform({translate:[6*v/2-v/2,t-6*v/2],rotate:[45,v/2,v/2]}).rect(g).end(),s++},H.prototype.geoOverlappingCircles=function(){var t,r,s,a,h,l,c,f=e(this.hash,0),u=i(f,0,15,25,200),p=u/2;for(this.svg.setWidth(6*p),this.svg.setHeight(6*p),r=0,c=0;6>c;c++)for(l=0;6>l;l++)h=e(this.hash,r),s=n(h),t=o(h),a={fill:t,opacity:s},this.svg.circle(l*p,c*p,p,a),0===l&&this.svg.circle(6*p,c*p,p,a),0===c&&this.svg.circle(l*p,6*p,p,a),0===l&&0===c&&this.svg.circle(6*p,6*p,p,a),r++},H.prototype.geoOctogons=function(){var t,r,s,a,h,l,f=i(e(this.hash,0),0,15,10,60),u=c(f);for(this.svg.setWidth(6*f),this.svg.setHeight(6*f),r=0,l=0;6>l;l++)for(h=0;6>h;h++)a=e(this.hash,r),s=n(a),t=o(a),this.svg.polyline(u,{fill:t,"fill-opacity":s,stroke:S,"stroke-opacity":A}).transform({translate:[h*f,l*f]}),r+=1},H.prototype.geoSquares=function(){var t,r,s,a,h,l,c=i(e(this.hash,0),0,15,10,60);for(this.svg.setWidth(6*c),this.svg.setHeight(6*c),r=0,l=0;6>l;l++)for(h=0;6>h;h++)a=e(this.hash,r),s=n(a),t=o(a),this.svg.rect(h*c,l*c,c,c,{fill:t,"fill-opacity":s,stroke:S,"stroke-opacity":A}),r+=1},H.prototype.geoConcentricCircles=function(){var t,r,s,a,h,l,c=e(this.hash,0),f=i(c,0,15,10,60),u=f/5;for(this.svg.setWidth(6*(f+u)),this.svg.setHeight(6*(f+u)),r=0,l=0;6>l;l++)for(h=0;6>h;h++)a=e(this.hash,r),s=n(a),t=o(a),this.svg.circle(h*f+h*u+(f+u)/2,l*f+l*u+(f+u)/2,f/2,{fill:"none",stroke:t,opacity:s,"stroke-width":u+"px"}),a=e(this.hash,39-r),s=n(a),t=o(a),this.svg.circle(h*f+h*u+(f+u)/2,l*f+l*u+(f+u)/2,f/4,{fill:t,"fill-opacity":s}),r+=1},H.prototype.geoOverlappingRings=function(){var t,r,s,a,h,l,c,f=e(this.hash,0),u=i(f,0,15,10,60),p=u/4;for(this.svg.setWidth(6*u),this.svg.setHeight(6*u),r=0,c=0;6>c;c++)for(l=0;6>l;l++)h=e(this.hash,r),s=n(h),t=o(h),a={fill:"none",stroke:t,opacity:s,"stroke-width":p+"px"},this.svg.circle(l*u,c*u,u-p/2,a),0===l&&this.svg.circle(6*u,c*u,u-p/2,a),0===c&&this.svg.circle(l*u,6*u,u-p/2,a),0===l&&0===c&&this.svg.circle(6*u,6*u,u-p/2,a),r+=1},H.prototype.geoTriangles=function(){var t,r,s,a,h,l,c,u,p=e(this.hash,0),g=i(p,0,15,15,80),v=g/2*Math.sqrt(3),y=f(g,v);for(this.svg.setWidth(3*g),this.svg.setHeight(6*v),r=0,u=0;6>u;u++)for(c=0;6>c;c++)l=e(this.hash,r),s=n(l),t=o(l),h={fill:t,"fill-opacity":s,stroke:S,"stroke-opacity":A},a=u%2===0?c%2===0?180:0:c%2!==0?180:0,this.svg.polyline(y,h).transform({translate:[c*g*.5-g/2,v*u],rotate:[a,g/2,v/2]}),0===c&&this.svg.polyline(y,h).transform({translate:[6*g*.5-g/2,v*u],rotate:[a,g/2,v/2]}),r+=1},H.prototype.geoDiamonds=function(){var t,r,s,a,h,l,c,f,p=i(e(this.hash,0),0,15,10,50),g=i(e(this.hash,1),0,15,10,50),v=u(p,g);for(this.svg.setWidth(6*p),this.svg.setHeight(3*g),s=0,f=0;6>f;f++)for(c=0;6>c;c++)l=e(this.hash,s),a=n(l),r=o(l),h={fill:r,"fill-opacity":a,stroke:S,"stroke-opacity":A},t=f%2===0?0:p/2,this.svg.polyline(v,h).transform({translate:[c*p-p/2+t,g/2*f-g/2]}),0===c&&this.svg.polyline(v,h).transform({translate:[6*p-p/2+t,g/2*f-g/2]}),0===f&&this.svg.polyline(v,h).transform({translate:[c*p-p/2+t,g/2*6-g/2]}),0===c&&0===f&&this.svg.polyline(v,h).transform({translate:[6*p-p/2+t,g/2*6-g/2]}),s+=1},H.prototype.geoNestedSquares=function(){var t,r,s,a,h,l,c,f=i(e(this.hash,0),0,15,4,12),u=7*f;for(this.svg.setWidth(6*(u+f)+6*f),this.svg.setHeight(6*(u+f)+6*f),r=0,c=0;6>c;c++)for(l=0;6>l;l++)h=e(this.hash,r),s=n(h),t=o(h),a={fill:"none",stroke:t,opacity:s,"stroke-width":f+"px"},this.svg.rect(l*u+l*f*2+f/2,c*u+c*f*2+f/2,u,u,a),h=e(this.hash,39-r),s=n(h),t=o(h),a={fill:"none",stroke:t,opacity:s,"stroke-width":f+"px"},this.svg.rect(l*u+l*f*2+f/2+2*f,c*u+c*f*2+f/2+2*f,3*f,3*f,a),r+=1},H.prototype.geoMosaicSquares=function(){var t,r,s,o=i(e(this.hash,0),0,15,15,50);for(this.svg.setWidth(8*o),this.svg.setHeight(8*o),t=0,s=0;4>s;s++)for(r=0;4>r;r++)r%2===0?s%2===0?v(this.svg,r*o*2,s*o*2,o,e(this.hash,t)):g(this.svg,r*o*2,s*o*2,o,[e(this.hash,t),e(this.hash,t+1)]):s%2===0?g(this.svg,r*o*2,s*o*2,o,[e(this.hash,t),e(this.hash,t+1)]):v(this.svg,r*o*2,s*o*2,o,e(this.hash,t)),t+=1},H.prototype.geoPlaid=function(){var t,r,s,i,a,h,l,c=0,f=0;for(r=0;36>r;)i=e(this.hash,r),c+=i+5,l=e(this.hash,r+1),s=n(l),t=o(l),a=l+5,this.svg.rect(0,c,"100%",a,{opacity:s,fill:t}),c+=a,r+=2;for(r=0;36>r;)i=e(this.hash,r),f+=i+5,l=e(this.hash,r+1),s=n(l),t=o(l),h=l+5,this.svg.rect(f,0,h,"100%",{opacity:s,fill:t}),f+=h,r+=2;this.svg.setWidth(f),this.svg.setHeight(c)},H.prototype.geoTessellation=function(){var t,r,s,a,h,l=i(e(this.hash,0),0,15,5,40),c=l*Math.sqrt(3),f=2*l,u=l/2*Math.sqrt(3),p=y(l,u),g=3*l+2*u,v=2*c+2*l;for(this.svg.setWidth(g),this.svg.setHeight(v),r=0;20>r;r++)switch(h=e(this.hash,r),s=n(h),t=o(h),a={stroke:S,"stroke-opacity":A,fill:t,"fill-opacity":s,"stroke-width":1},r){case 0:this.svg.rect(-l/2,-l/2,l,l,a),this.svg.rect(g-l/2,-l/2,l,l,a),this.svg.rect(-l/2,v-l/2,l,l,a),this.svg.rect(g-l/2,v-l/2,l,l,a);break;case 1:this.svg.rect(f/2+u,c/2,l,l,a);break;case 2:this.svg.rect(-l/2,v/2-l/2,l,l,a),this.svg.rect(g-l/2,v/2-l/2,l,l,a);break;case 3:this.svg.rect(f/2+u,1.5*c+l,l,l,a);break;case 4:this.svg.polyline(p,a).transform({translate:[l/2,-l/2],rotate:[0,l/2,u/2]}),this.svg.polyline(p,a).transform({translate:[l/2,v- -l/2],rotate:[0,l/2,u/2],scale:[1,-1]});break;case 5:this.svg.polyline(p,a).transform({translate:[g-l/2,-l/2],rotate:[0,l/2,u/2],scale:[-1,1]}),this.svg.polyline(p,a).transform({translate:[g-l/2,v+l/2],rotate:[0,l/2,u/2],scale:[-1,-1]});break;case 6:this.svg.polyline(p,a).transform({translate:[g/2+l/2,c/2]});break;case 7:this.svg.polyline(p,a).transform({translate:[g-g/2-l/2,c/2],scale:[-1,1]});break;case 8:this.svg.polyline(p,a).transform({translate:[g/2+l/2,v-c/2],scale:[1,-1]});break;case 9:this.svg.polyline(p,a).transform({translate:[g-g/2-l/2,v-c/2],scale:[-1,-1]});break;case 10:this.svg.polyline(p,a).transform({translate:[l/2,v/2-l/2]});break;case 11:this.svg.polyline(p,a).transform({translate:[g-l/2,v/2-l/2],scale:[-1,1]});break;case 12:this.svg.rect(0,0,l,l,a).transform({translate:[l/2,l/2],rotate:[-30,0,0]});break;case 13:this.svg.rect(0,0,l,l,a).transform({scale:[-1,1],translate:[-g+l/2,l/2],rotate:[-30,0,0]});break;case 14:this.svg.rect(0,0,l,l,a).transform({translate:[l/2,v/2-l/2-l],rotate:[30,0,l]});break;case 15:this.svg.rect(0,0,l,l,a).transform({scale:[-1,1],translate:[-g+l/2,v/2-l/2-l],rotate:[30,0,l]});break;case 16:this.svg.rect(0,0,l,l,a).transform({scale:[1,-1],translate:[l/2,-v+v/2-l/2-l],rotate:[30,0,l]});break;case 17:this.svg.rect(0,0,l,l,a).transform({scale:[-1,-1],translate:[-g+l/2,-v+v/2-l/2-l],rotate:[30,0,l]});break;case 18:this.svg.rect(0,0,l,l,a).transform({scale:[1,-1],translate:[l/2,-v+l/2],rotate:[-30,0,0]});break;case 19:this.svg.rect(0,0,l,l,a).transform({scale:[-1,-1],translate:[-g+l/2,-v+l/2],rotate:[-30,0,0]})}}}).call(this,t("buffer").Buffer)},{"./color":2,"./sha1":4,"./svg":5,buffer:7,extend:8}],4:[function(t,r){"use strict";function s(){function t(){for(var t=16;80>t;t++){var r=f[t-3]^f[t-8]^f[t-14]^f[t-16];f[t]=r<<1|r>>>31}var s,e,i=n,o=a,p=h,g=l,v=c;for(t=0;80>t;t++){20>t?(s=g^o&(p^g),e=1518500249):40>t?(s=o^p^g,e=1859775393):60>t?(s=o&p|g&(o|p),e=2400959708):(s=o^p^g,e=3395469782);var y=(i<<5|i>>>27)+s+v+e+(0|f[t]);v=g,g=p,p=o<<30|o>>>2,o=i,i=y}for(n=n+i|0,a=a+o|0,h=h+p|0,l=l+g|0,c=c+v|0,u=0,t=0;16>t;t++)f[t]=0}function r(r){f[u]|=(255&r)<e;e++)r(t.charCodeAt(e))}function e(t){if("string"==typeof t)return s(t);var e=t.length;g+=8*e;for(var i=0;e>i;i++)r(t[i])}function i(t){for(var r="",s=28;s>=0;s-=4)r+=(t>>s&15).toString(16);return r}function o(){r(128),(u>14||14===u&&24>p)&&t(),u=14,p=24,r(0),r(0),r(g>0xffffffffff?g/1099511627776:0),r(g>4294967295?g/4294967296:0);for(var s=24;s>=0;s-=8)r(g>>s);return i(n)+i(a)+i(h)+i(l)+i(c)}var n=1732584193,a=4023233417,h=2562383102,l=271733878,c=3285377520,f=new Uint32Array(80),u=0,p=24,g=0;return{update:e,digest:o}}r.exports=function(t){if(void 0===t)return s();var r=s();return r.update(t),r.digest()}},{}],5:[function(t,r){"use strict";function s(){return this.width=100,this.height=100,this.svg=i("svg"),this.context=[],this.setAttributes(this.svg,{xmlns:"http://www.w3.org/2000/svg",width:this.width,height:this.height}),this}var e=t("extend"),i=t("./xml");r.exports=s,s.prototype.currentContext=function(){return this.context[this.context.length-1]||this.svg},s.prototype.end=function(){return this.context.pop(),this},s.prototype.currentNode=function(){var t=this.currentContext();return t.lastChild||t},s.prototype.transform=function(t){return this.currentNode().setAttribute("transform",Object.keys(t).map(function(r){return r+"("+t[r].join(",")+")"}).join(" ")),this},s.prototype.setAttributes=function(t,r){Object.keys(r).forEach(function(s){t.setAttribute(s,r[s])})},s.prototype.setWidth=function(t){this.svg.setAttribute("width",Math.floor(t))},s.prototype.setHeight=function(t){this.svg.setAttribute("height",Math.floor(t))},s.prototype.toString=function(){return this.svg.toString()},s.prototype.rect=function(t,r,s,o,n){var a=this;if(Array.isArray(t))return t.forEach(function(t){a.rect.apply(a,t.concat(n))}),this;var h=i("rect");return this.currentContext().appendChild(h),this.setAttributes(h,e({x:t,y:r,width:s,height:o},n)),this},s.prototype.circle=function(t,r,s,o){var n=i("circle");return this.currentContext().appendChild(n),this.setAttributes(n,e({cx:t,cy:r,r:s},o)),this},s.prototype.path=function(t,r){var s=i("path");return this.currentContext().appendChild(s),this.setAttributes(s,e({d:t},r)),this},s.prototype.polyline=function(t,r){var s=this;if(Array.isArray(t))return t.forEach(function(t){s.polyline(t,r)}),this;var o=i("polyline");return this.currentContext().appendChild(o),this.setAttributes(o,e({points:t},r)),this},s.prototype.group=function(t){var r=i("g");return this.currentContext().appendChild(r),this.context.push(r),this.setAttributes(r,e({},t)),this}},{"./xml":6,extend:8}],6:[function(t,r){"use strict";var s=r.exports=function(t){return this instanceof s?(this.tagName=t,this.attributes=Object.create(null),this.children=[],this.lastChild=null,this):new s(t)};s.prototype.appendChild=function(t){return this.children.push(t),this.lastChild=t,this},s.prototype.setAttribute=function(t,r){return this.attributes[t]=r,this},s.prototype.toString=function(){var t=this;return["<",t.tagName,Object.keys(t.attributes).map(function(r){return[" ",r,'="',t.attributes[r],'"'].join("")}).join(""),">",t.children.map(function(t){return t.toString()}).join(""),"",t.tagName,">"].join("")}},{}],7:[function(){},{}],8:[function(t,r){function s(t){if(!t||"[object Object]"!==i.call(t)||t.nodeType||t.setInterval)return!1;var r=e.call(t,"constructor"),s=e.call(t.constructor.prototype,"isPrototypeOf");if(t.constructor&&!r&&!s)return!1;var o;for(o in t);return void 0===o||e.call(t,o)}var e=Object.prototype.hasOwnProperty,i=Object.prototype.toString;r.exports=function o(){var t,r,e,i,n,a,h=arguments[0]||{},l=1,c=arguments.length,f=!1;for("boolean"==typeof h&&(f=h,h=arguments[1]||{},l=2),"object"!=typeof h&&"function"!=typeof h&&(h={});c>l;l++)if(null!=(t=arguments[l]))for(r in t)e=h[r],i=t[r],h!==i&&(f&&i&&(s(i)||(n=Array.isArray(i)))?(n?(n=!1,a=e&&Array.isArray(e)?e:[]):a=e&&s(e)?e:{},h[r]=o(f,a,i)):void 0!==i&&(h[r]=i));return h}},{}]},{},[1])(1)});
\ No newline at end of file
diff --git a/pythonz/apps/static/js/jquery.alphaindex.min.js b/pythonz/apps/static/js/jquery.alphaindex.min.js
new file mode 100644
index 00000000..1a6a1d80
--- /dev/null
+++ b/pythonz/apps/static/js/jquery.alphaindex.min.js
@@ -0,0 +1,7 @@
+/**
+ * jquery-alphaindex
+ * https://github.com/idlesign/jquery-alphaindex
+ *
+ * Distributed under BSD License.
+ */
+!function(a){"use strict";a.fn.makeAlphaIndex=function(b){var c=this,d=a.extend({},a.fn.makeAlphaIndex.defaults,b),e=function(b){var c={};return a.each(a("li",b),function(b,d){var e=a(d),f=e.text().replace(/\s+/g," ").trim(),g=f[0];if(!g)return void e.remove();e.hide(),g=g.toLowerCase();var h=c[g];void 0===h?c[g]=[e]:h.push(e)}),c},f=function(b){if(void 0===b)return void a("li",c).toggle();if("boolean"==typeof b)return void a("li",c).toggle(b);"object"==typeof b&&(b=a(b).data("idxChar"));var d=!1,e=c.alphaIndex,f=e[b];a.each(e,function(c,d){c!==b&&a.each(d,function(a,b){b.hide()})}),f&&a.each(f,function(a,b){b.toggle(),d=b.is(":visible")}),a.each(a("a",c.alphaIndexBar),function(c,e){var f=a(e);f.removeClass("current"),f.data("idxChar")===b&&d&&f.addClass("current")})},g=function(b,c){var e=Object.keys(c).sort(),g=a("
");return b.indexChars=e,b.before(g),a.each(e,function(b,e){var f=a(''+e.toUpperCase()+" ");d.showItemsCount&&f.append(""+c[e].length+" "),f.data("idxChar",e),g.append(f),f.wrap(" ")}),g.addClass("alpha-index-bar"),a("a",g).on("click",function(a){f(a.target)}),g},h=function(a){if(d.activateFirstIndex){var b=a.indexChars[0];void 0!==b&&f(b)}};c.hide();var i=e(c),j=g(c,i);return c.addClass("alpha-index-list"),c.alphaIndex=i,c.alphaIndexBar=j,c.alphaIndexToggle=f,c.show(),h(c),c},a.fn.makeAlphaIndex.defaults={activateFirstIndex:!0,showItemsCount:!0}}(jQuery);
\ No newline at end of file
diff --git a/pythonz/apps/static/js/pythonz.min.js b/pythonz/apps/static/js/pythonz.min.js
new file mode 100644
index 00000000..342ab002
--- /dev/null
+++ b/pythonz/apps/static/js/pythonz.min.js
@@ -0,0 +1 @@
+var pythonz={bootstrap:function(){"use strict";$(function(){pythonz.makeGeopatterns(),pythonz.markUser(),pythonz.toggleTags(),sitecats.bootstrap(),sitecats.make_cloud("box-tags"),$(".sticky").sticky({topSpacing:80,zIndex:1}),$(".tooltipped").tooltip()})},toggleTags:function(){"use strict";$.each($(".tags_box"),function(t,e){var n=$(e);0===$(".categories_box",n).length&&n.hide()})},makeGeopatterns:function(){"use strict";$.each($("[data-geopattern]"),function(t,e){var n=$(e);n.css("background-image",GeoPattern.generate(n.data("geopattern")+"a").toDataUrl())})},markUser:function(){"use strict";$(".py_user").each(function(t,e){var n=e.innerHTML.replace(/\[u:(\d+):\s*([^\]]+)\s*\]/g,'$2 ');$(e).html(n)})},activateCommentsTab:function(t){"use strict";setTimeout(function(){var t=0;$.each(["comments_vk"],function(e,n){var a=parseInt($("#"+n+"_cnt").text());a>t&&($('a[href="#'+n+'"]',"#tabs-comments").tab("show"),t=a)})},t)},initEditor:function(t){"use strict";if(t)return new SimpleMDE({element:t,forceSync:!0,indentWithTabs:!1,spellChecker:!1,toolbar:["bold","italic",e("accent","flag","Акцент"),e("quote","quote-left","Цитата"),e("list_ul","list-ul","Маркированный список"),e("table","table","Таблица"),"|","image","link","|",e("note","flag-o","На заметку"),e("warning","exclamation-triangle","Внимание"),e("code","code","Код"),"|",e("gist","github","Gist"),e("podster","headphones","Подкаст с podster.fm"),"|","fullscreen"],insertTexts:{image:[".. image:: ",""],gist:[".. gist:: ",""],note:[".. note:: ",""],warning:[".. warning:: ",""],podster:[".. podster:: ",""],link:["`","<>`_"],accent:["``","``"],table:["\n.. table::\n ","|\n\n\n"],code:["\n.. code::\n","\n\n\n"],quote:["\n```\n","\n```"],list_ul:["\n* ","\n\n"]}});function e(t,e,n){return{name:t,action:function(e){!function(t,e){!function(t,e,n,a){if(!/editor-preview-active/.test(t.getWrapperElement().lastChild.className)){var s,o=n[0],i=n[1],r=t.getCursor("start"),c=t.getCursor("end");a&&(i=i.replace("#url#",a)),e?(o=(s=t.getLine(r.line)).slice(0,r.ch),i=s.slice(r.ch),t.replaceRange(o+i,{line:r.line,ch:0})):(s=t.getSelection(),t.replaceSelection(o+s+i),r.ch+=o.length,r!==c&&(c.ch+=o.length)),t.setSelection(r,c),t.focus()}}(t.codemirror,t.getState()[e],t.options.insertTexts[e])}(e,t)},className:"fa fa-"+e,title:n}}},Reference:{RULE_PYVERSION_ADDED:[/\+py([\w\.]+)/g,' '],RULE_PYVERSION_REMOVED:[/-py([\w\.]+)/g,' '],RULE_LITERAL:[/'([^']+)'/g,'$1 '],RULE_UNDERMETHOD:[/(__[^\s]+__)/g,"$1 "],RULE_EMDASH:[/\s+-\s+/g," — "],decorateDescription:function(t){"use strict";this.decorateArea(t,[this.RULE_PYVERSION_REMOVED,this.RULE_PYVERSION_ADDED,this.RULE_EMDASH])},decorateFuncResult:function(t){"use strict";this.decorateArea(t,[this.RULE_PYVERSION_REMOVED,this.RULE_PYVERSION_ADDED,this.RULE_EMDASH])},decorateFuncParams:function(t){"use strict";this.decorateArea(t,[[/([^->]+)(\s--)/g,function(t,e,n){return' '+(e=e.replace(/([^\s]+)(\s.+)/g,'$1$2 '))+" "+n}],[/--/g,":"],this.RULE_PYVERSION_REMOVED,this.RULE_PYVERSION_ADDED,this.RULE_LITERAL,this.RULE_UNDERMETHOD,this.RULE_EMDASH])},decorateArea:function(t,e){"use strict";var n=$("#"+t),a=n.html();void 0!==a&&$.each(e,function(t,e){a=a.replace(e[0],e[1])}),n.html(a)}},Map:function(t,e){"use strict";var n=this,a=$("#"+t),s=e;this.getBoundsForCoords=function(t){return ymaps.util.bounds.getCenterAndZoom(t,[a.width(),a.height()])},this.getPlacemarksFromMapObjects=function(t){var e=[],n=0;return void 0===t&&(t=s),$.each(t,function(t,a){var s=a.coords,o=a.title,i=a.descr,r=a.link;e[n]=new ymaps.Placemark(s,{balloonContentHeader:o,balloonContentBody:i,balloonContentFooter:r,clusterCaption:o,place_id:t},{hideIconOnBalloonOpen:!1,preset:"islands#darkBlueCircleDotIcon"}),n++}),e},this.getClusterer=function(){var t=n.getPlacemarksFromMapObjects(),e=new ymaps.Clusterer({preset:"islands#darkBlueClusterIcons",clusterDisableClickZoom:!0,clusterBalloonPanelMaxMapArea:0,clusterBalloonContentLayoutWidth:250,clusterBalloonContentLayoutHeight:100,clusterBalloonLeftColumnWidth:100});return e.add(t),e},this.initMap=function(){ymaps.ready(function(){var t=n.getClusterer(),e=n.getBoundsForCoords(t.getBounds());$.extend(e,{controls:["zoomControl"]}),new ymaps.Map(a.attr("id"),e).geoObjects.add(t)})},n.initMap()}};pythonz.bootstrap();
diff --git a/pythonz/apps/static_src/pythonz.js b/pythonz/apps/static_src/pythonz.js
new file mode 100644
index 00000000..f1b728ab
--- /dev/null
+++ b/pythonz/apps/static_src/pythonz.js
@@ -0,0 +1,316 @@
+/*globals $ sitecats SimpleMDE ymaps */
+
+var pythonz = {
+
+ bootstrap: function() {
+ "use strict";
+
+ $(function(){
+ pythonz.makeGeopatterns();
+ pythonz.markUser();
+ pythonz.toggleTags();
+
+ sitecats.bootstrap();
+ sitecats.make_cloud('box-tags');
+
+ $('.sticky').sticky({
+ topSpacing: 80,
+ zIndex: 1
+ });
+
+ $('.tooltipped').tooltip();
+ });
+ },
+
+ toggleTags: function () {
+ "use strict";
+ $.each($('.tags_box'), function(idx, el) {
+ var $el = $(el);
+ if ($('.categories_box', $el).length === 0){
+ $el.hide();
+ }
+ });
+ },
+
+ makeGeopatterns: function () {
+ "use strict";
+ $.each($('[data-geopattern]'), function(idx, el) {
+ var $el = $(el);
+ $el.css('background-image', GeoPattern.generate($el.data('geopattern') + 'a').toDataUrl());
+ });
+ },
+
+ markUser: function() {
+ "use strict";
+ $('.py_user').each(function(i, el) {
+ var html = el.innerHTML.replace(/\[u:(\d+):\s*([^\]]+)\s*\]/g, '$2 ');
+ $(el).html(html);
+ });
+ },
+
+ activateCommentsTab: function(timeout) {
+ "use strict";
+ setTimeout(function(){
+ var maxCount = 0,
+ ids = ['comments_vk'];
+
+ $.each(ids, function(idx, tabId){
+ var count = parseInt($('#' + tabId + '_cnt').text());
+ if (count > maxCount) {
+ $('a[href="#' + tabId + '"]', '#tabs-comments').tab('show');
+ maxCount = count;
+ }
+ });
+
+
+ }, timeout);
+ },
+
+ initEditor: function(textareaEl) {
+ "use strict";
+
+ if (!textareaEl) {
+ return;
+ }
+
+ // _replaceSelection() взята из исходников SimpleMDE.
+ // https://github.com/NextStepWebs/simplemde-markdown-editor
+ // После минификации она обфусцируется и далее недоступна.
+
+ function _replaceSelection(cm, active, startEnd, url) {
+ if(/editor-preview-active/.test(cm.getWrapperElement().lastChild.className)) {return;}
+
+ var text;
+ var start = startEnd[0];
+ var end = startEnd[1];
+ var startPoint = cm.getCursor("start");
+ var endPoint = cm.getCursor("end");
+ if(url) {
+ end = end.replace("#url#", url);
+ }
+ if(active) {
+ text = cm.getLine(startPoint.line);
+ start = text.slice(0, startPoint.ch);
+ end = text.slice(startPoint.ch);
+ cm.replaceRange(start + end, {
+ line: startPoint.line,
+ ch: 0
+ });
+ } else {
+ text = cm.getSelection();
+ cm.replaceSelection(start + text + end);
+
+ startPoint.ch += start.length;
+ if(startPoint !== endPoint) {
+ endPoint.ch += start.length;
+ }
+ }
+ cm.setSelection(startPoint, endPoint);
+ cm.focus();
+ }
+
+ function simpleAction(editor, buttonName){
+ _replaceSelection(
+ editor.codemirror,
+ editor.getState()[buttonName],
+ editor.options.insertTexts[buttonName]);
+ }
+
+ function getButton(name, icon, title) {
+ return {
+ name: name,
+ action: function (editor) {simpleAction(editor, name);},
+ className: 'fa fa-' + icon,
+ title: title
+ };
+ }
+
+ return new SimpleMDE({
+ element: textareaEl,
+ forceSync: true,
+ indentWithTabs: false,
+ spellChecker: false,
+ toolbar: [
+ 'bold',
+ 'italic',
+ getButton('accent', 'flag', 'Акцент'),
+ getButton('quote', 'quote-left', 'Цитата'),
+ getButton('list_ul', 'list-ul', 'Маркированный список'),
+ getButton('table', 'table', 'Таблица'),
+ '|',
+ 'image',
+ 'link',
+ '|',
+ getButton('note', 'flag-o', 'На заметку'),
+ getButton('warning', 'exclamation-triangle', 'Внимание'),
+ getButton('code', 'code', 'Код'),
+ '|',
+ getButton('gist', 'github', 'Gist'),
+ getButton('podster', 'headphones', 'Подкаст с podster.fm'),
+ '|',
+ 'fullscreen'
+ ],
+ insertTexts: {
+ image: ['.. image:: ', ''],
+ gist: ['.. gist:: ', ''],
+ note: ['.. note:: ', ''],
+ warning: ['.. warning:: ', ''],
+ podster: ['.. podster:: ', ''],
+ link: ['`', '<>`_'],
+ accent: ['``', '``'],
+ table: ['\n.. table::\n ', '|\n\n\n'],
+ code: ['\n.. code::\n', '\n\n\n'],
+ quote: ['\n```\n', '\n```'],
+ list_ul: ['\n* ', '\n\n']
+ }
+ });
+
+ },
+
+ Reference: {
+
+ RULE_PYVERSION_ADDED: [
+ /\+py([\w\.]+)/g,
+ ' '],
+
+ RULE_PYVERSION_REMOVED: [
+ /-py([\w\.]+)/g,
+ ' '],
+
+ RULE_LITERAL: [/'([^']+)'/g, '$1 '],
+ RULE_UNDERMETHOD: [/(__[^\s]+__)/g, '$1 '],
+ RULE_EMDASH: [/\s+-\s+/g, ' — '],
+
+ decorateDescription: function(areaId) {
+ "use strict";
+ this.decorateArea(areaId,
+ [
+ this.RULE_PYVERSION_REMOVED,
+ this.RULE_PYVERSION_ADDED,
+ this.RULE_EMDASH
+ ]
+ );
+ },
+
+ decorateFuncResult: function(areaId) {
+ "use strict";
+ this.decorateArea(areaId,
+ [
+ this.RULE_PYVERSION_REMOVED,
+ this.RULE_PYVERSION_ADDED,
+ this.RULE_EMDASH
+ ]
+ );
+ },
+
+ decorateFuncParams: function(areaId) {
+ "use strict";
+ var funcProcessArgs = function (matchStr, argName, separator) {
+ argName = argName.replace(/([^\s]+)(\s.+)/g, '$1$2 ')
+ return ' ' + argName + ' ' + separator;
+ };
+
+ this.decorateArea(areaId,
+ [
+ [/([^->]+)(\s--)/g, funcProcessArgs],
+ [/--/g, ':'],
+ this.RULE_PYVERSION_REMOVED,
+ this.RULE_PYVERSION_ADDED,
+ this.RULE_LITERAL,
+ this.RULE_UNDERMETHOD,
+ this.RULE_EMDASH
+ ]
+ );
+ },
+
+ decorateArea: function(areaId, rules) {
+ "use strict";
+ var $area = $('#' + areaId),
+ html = $area.html();
+
+ if (html !== undefined) {
+ $.each(rules, function (idx, rule) {
+ html = html.replace(rule[0], rule[1]);
+ });
+ }
+
+ $area.html(html);
+
+ }
+
+ },
+
+ Map: function (mapElementId, objects) {
+ "use strict";
+ var self = this,
+ _mapEl = $('#' + mapElementId),
+ _mapObjs = objects;
+
+ this.getBoundsForCoords = function(coords) {
+ return ymaps.util.bounds.getCenterAndZoom(coords, [_mapEl.width(), _mapEl.height()])
+ };
+
+ this.getPlacemarksFromMapObjects = function(objects) {
+ var allMarks = [],
+ markIdx = 0;
+
+ if (objects===undefined) {
+ objects = _mapObjs;
+ }
+
+ $.each(objects, function(id, props) {
+ var coords = props.coords,
+ title = props.title,
+ descr = props.descr,
+ link = props.link;
+
+ allMarks[markIdx] = new ymaps.Placemark(coords, {
+ balloonContentHeader: title,
+ balloonContentBody: descr,
+ balloonContentFooter: link,
+ clusterCaption: title,
+ place_id: id
+ }, {
+ hideIconOnBalloonOpen: false,
+ preset: 'islands#darkBlueCircleDotIcon'
+ });
+
+ markIdx++;
+ });
+ return allMarks;
+ };
+
+ this.getClusterer = function() {
+ var objs = self.getPlacemarksFromMapObjects(),
+ clusterer = new ymaps.Clusterer({
+ preset: 'islands#darkBlueClusterIcons',
+ clusterDisableClickZoom: true,
+ clusterBalloonPanelMaxMapArea: 0,
+ clusterBalloonContentLayoutWidth: 250,
+ clusterBalloonContentLayoutHeight: 100,
+ clusterBalloonLeftColumnWidth: 100
+ });
+ clusterer.add(objs);
+ return clusterer;
+ };
+
+ this.initMap = function () {
+ ymaps.ready(function () {
+ var clusterer = self.getClusterer(),
+ mapState = self.getBoundsForCoords(clusterer.getBounds());
+
+ $.extend(mapState, {
+ controls: ['zoomControl']
+ });
+
+ var map = new ymaps.Map(_mapEl.attr('id'), mapState);
+ map.geoObjects.add(clusterer);
+ });
+ };
+
+ self.initMap();
+ }
+
+};
+
+pythonz.bootstrap();
diff --git a/data/static_dist/pythonz.less b/pythonz/apps/static_src/pythonz.less
similarity index 54%
rename from data/static_dist/pythonz.less
rename to pythonz/apps/static_src/pythonz.less
index 12abf224..b77927d1 100644
--- a/data/static_dist/pythonz.less
+++ b/pythonz/apps/static_src/pythonz.less
@@ -8,13 +8,14 @@
@cl__white: #fff;
@cl__black: #000;
+@cl__blue2: #4f90c6;
+
@gap__max: 40px;
@gap__mid: 20px;
@gap__min: 10px;
@gap__no: 0;
@fnt__brand: Belleza, sans-serif;
-@fnt__headers: 'PT Serif', serif;
//------------------------------------------
// Helpers.
@@ -43,101 +44,92 @@
color: @cl__gray;
}
-.pad__t_min {
- padding-top: @gap__min;
-}
-
-.pad__t_mid {
- padding-top: @gap__mid;
-}
-
-.marg__t_min {
- margin-top: @gap__min;
-}
-
-.marg__t_mid {
- margin-top: @gap__mid;
-}
-
-.marg__t_max {
- margin-top: @gap__max;
-}
-.marg__b_min {
- margin-bottom: @gap__min;
-}
+//------------------------------------------
+// alphaindex overrides.
-.marg__b_mid {
- margin-bottom: @gap__mid;
+.alpha-index-bar {
+ a.current {
+ text-decoration: underline;
+ }
+ li {
+ display: inline-block;
+ margin-right: @gap__min;
+ }
}
-.marg__b_max {
- margin-bottom: @gap__max;
+.alpha-index-list {
+ padding-left: @gap__mid;
}
-.marg__l_min {
- margin-left: @gap__min;
-}
+//------------------------------------------
+// Bootstrap overrides.
-.marg__l_mid {
- margin-left: @gap__mid;
+.container {
+ border-radius: @gap__min;
}
-.marg__r_min {
- margin-right: @gap__min;
+.breadcrumbs ol {
+ border-radius: @gap__min;
}
-.marg__r_mid {
- margin-right: @gap__mid;
+.lead {
+ margin-bottom: @gap__mid;
}
-
-//------------------------------------------
-// Bootstrap overrides.
-
.footer {
- border-bottom: none;
- margin-top: @gap__max;
padding-top: @gap__mid;
- font-size: 12px;
+ font-size: 14px;
+
+ .menu {
+ padding-left: @gap__no;
+ margin-left: @gap__no;
+ }
.menu li {
display: inline-block;
margin-right: @gap__min;
}
+ address i, a {
+ color: darken(@cl__white, 20%);
+ }
}
-.panel {
- font-size: 14px;
- .panel-title {
- font-size: 16px;
- }
- .panel-body {
- color: lighten(@cl__black, 30%);
- ul, ol {
- padding-left: inherit;
- padding-right: inherit;
- }
+.page-header {
+ .icon_entity {
+ margin-right: @gap__min;
+ }
+ border-bottom: none;
+ h1 {
+ word-break: break-word;
+ font-size: 1.8em;
+ text-transform: uppercase;
+ .gepattern-mixin;
}
}
-.page-header {
- margin-top: 0;
+.header {
+ border-bottom: lighten(@cl__gray, 30%) solid 2px;
}
.navbar {
margin-bottom: 0;
input {
margin-top: 4px;
- height: 30px;
- max-width: 180px;
- font-size: 15px
+ height: 25px;
+ max-width: 150px;
+ min-width: 150px;
+ font-size: 10px
}
.navbar-brand img {
margin: 5px;
}
}
+.navbar-brand {
+ padding: 6px 6px 6px @gap__mid;
+}
+
.control-label {
font-weight: normal;
color: darken(@cl__white, 60%);
@@ -148,38 +140,46 @@
text-decoration: underline;
}
-.breadcrumb {
- font-size: 12px;
- padding: 0;
- margin: 0;
+.well a.btn {
+ text-decoration: none;
+}
+
+.badge-primary a, .badge-info a, .badge-danger a {
+ color: @cl__white !important;
+}
+
+address .fa {
+ vertical-align: middle;
+ color: @cl__black;
+}
+
+address .fa-2x {
+ vertical-align: middle;
+ color: @cl__green;
}
//------------------------------------------
// Html overrides.
-.b-forgetmenot_size_l {
- height: 28px !important;
+a {
+ color: @cl__blue;
}
-html, body {
- padding-top: @gap__mid;
+kbd {
+ background-color: @cl__blue2;
}
-header {
- h1 {
- font-family: @fnt__brand;
- font-size: 45px;
- }
- a:hover {
- text-decoration: none;
- }
+
+code {
+ color: @cl__black !important;
}
-h1, h2, h3, h4, h5, h6 {
- font-family: @fnt__headers;
+body {
+ background: #efefef;
}
+
h6 {
padding-bottom: @gap__min;
font-weight: bold;
@@ -190,44 +190,50 @@ h6 {
input[type=checkbox] {
display: inline-block;
- width: @gap__min;
- float: right;
+ width: @gap__mid;
+ vertical-align: bottom;
}
//------------------------------------------
// Custom elements.
-#page_controls {
- width: 100%;
- text-align: center;
- form {
- display: inline;
- }
+.b-forgetmenot_size_l {
+ height: 28px !important;
}
-#bar__most_voted {
+#box-topvoted {
background-color: lighten(@cl__gray, 45%);
border-radius: 10px;
+
+ .listing-item {
+ min-width: 50px;
+ }
+
+}
+
+.ref-linked {
+ word-spacing: 6px;
}
//------------------------------------------
// Custom classes singles.
nav .submenu {
- border-left: 4px @cl__green solid;
+ border-left: 10px @cl__green solid;
+ border-right: 10px @cl__green solid;
+ li {
+ padding-left: @gap__min;
+ padding-right: @gap__min;
+ }
}
.body {
background-color: @cl__white;
- padding-top: @gap__max;
- padding-bottom: @gap__max;
-}
-
-.section {
- .marg__b_max;
+ padding-top: @gap__mid;
+ padding-bottom: @gap__mid;
}
.mod__has_tooltip {
@@ -236,7 +242,6 @@ nav .submenu {
.icon_entity {
font-size: @gap__max;
- margin-right: @gap__mid;
float: left;
color: @cl__blue;
}
@@ -245,12 +250,11 @@ nav .submenu {
vertical-align: top;
}
-.listing_item.small {
- min-height: 80px;
-}
-.float_panel {
+.panel-float {
min-width: 250px;
+ max-width: 300px;
+ padding: @gap__min;
}
.userlist_item {
@@ -284,14 +288,8 @@ nav .submenu {
}
}
-.vacancies {
- .label {
- font-family: Verdana, Arial, helvetica, sans-serif;
- }
-}
-.tags_box {
- font-family: Verdana, Arial, helvetica, sans-serif;
+#box-tags {
.big {
.title {
font-size: @gap__mid;
@@ -313,7 +311,6 @@ nav .submenu {
}
.categories_box {
margin-bottom: @gap__min;
- font-size: 14px;
}
.btn_remove {
margin-right: @gap__min;
@@ -346,45 +343,28 @@ nav .submenu {
padding: 0;
display: inline;
}
+ input {
+ font-size: 12px;
+ }
}
.zen {
.eng {
line-height: 1.3em;
- font-size: @gap__min;
- }
- h2 {
- line-height: 1.2em;
+ color: lighten(@cl__black, 40%);
}
.small {
- font-size: @gap__min;
text-align: right;
+ color: lighten(@cl__black, 40%);
}
}
-.features {
- .features_row {
- div {
- min-width: 100px;
- padding: @gap__min;
- }
- }
- i {
- color: lighten(@cl__black, 50%);
- font-size: 28px;
- }
- a {
- color: lighten(@cl__black, 85%);
- }
- i:hover, a:hover {
- color: @cl__blue;
- text-decoration: none;
- }
-}
-.cover_img {
+.img-cover {
+ text-align: center;
.icon_entity {
padding: @gap__mid;
+ margin-right: @gap__min;
color: lighten(@cl__gray, 20%);
min-width: 100px;
}
@@ -401,32 +381,118 @@ nav .submenu {
opacity: 1;
}
-.listing_item {
- padding: @gap__min;
- min-width: 350px;
- max-width: 350px;
- min-height: 150px;
- max-height: 150px;
- float: left;
- margin-right: @gap__mid;
- margin-top: @gap__mid;
- border-right: 1px solid lighten(@cl__gray, 40%);
+
+.listing-item {
+
+ min-width: 250px;
+
+ .img-thumbnail {
+ margin-right: @gap__min;
+ }
+}
+
+
+#box-index {
+
+ .card{
+ padding: @gap__no @gap__no @gap__no @gap__no;
+ border: none !important;
+ }
+
img {
- margin-right: @gap__mid;
- float: left;
+ border: @gap__no;
}
- sup {
- color: @cl__gray;
+
+ .feature {
+ background-color: lighten(@cl__black, 10%);
}
- .rating {
- position: absolute;
- margin-left: @gap__min;
+
+ .ruler {
+ height: 3px;
+ background-color: @cl__green;
}
- .description {
- overflow: hidden;
- .small {
- margin-top: @gap__min;
- font-size: @gap__min;
+
+ li {
+ color: @cl__gray;
+ }
+
+ h4 {
+ a {
+ color: lighten(@cl__black, 40%);
}
+ white-space: nowrap;
+ margin: @gap__no @gap__min @gap__mid @gap__min;
}
+
+}
+
+.gepattern-mixin() {
+ color: @cl__white;
+ background-color: #888;
+ letter-spacing: 2px;
+ padding: @gap__min;
+}
+
+.subtitle {
+ .gepattern-mixin;
+ display: inline-block;
+ font-size: 1.2em;
+}
+
+
+.ya-share2 {
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.navbar-light .navbar-nav .nav-link{
+ color: #4f4f4f !important;
+}
+
+.navbar-light .navbar-nav .active>.nav-link{
+ color: #4f4f4f !important;
+ font-weight: bold !important;
+}
+
+.dropdown-item{
+ color: #4f4f4f !important;
+}
+
+.breadcrumb-item.active {
+ color:#4f4f4f !important;
+}
+
+#rss{
+ color: #0F446E !important;
+}
+
+.page-item.active .page-link
+{
+ background-color: #0050A7 !important;
+ color: white !important;
+}
+
+.page-link{
+ color: #0050A7 !important;
+}
+
+.badge-success
+{
+ background-color: #155D25 !important;
+}
+
+.badge-info, .badge-primary {
+ background-color: #0E5E6B !important;
+}
+
+.cl__gray {
+ color: #555555;
+}
+
+.pep-href{
+ color: #254D6F !important;
+}
+
+#box-tags .title {
+ color: #555555 !important;
}
diff --git a/pythonz/apps/templates/_base.html b/pythonz/apps/templates/_base.html
new file mode 100644
index 00000000..907634b9
--- /dev/null
+++ b/pythonz/apps/templates/_base.html
@@ -0,0 +1,144 @@
+{% load sitetree cache etc_misc static %}
+{% get_static_prefix as STATIC_URL %}
+
+
+
+
+
+
+
+
+
+
+ {% block meta_og %}{% endblock %}
+
+
+
+ {% if item %}{% with url=item.absolute_url_prefixed %}
+
+
+ {% endwith %}{% endif %}
+
+ {% include "sub/static_base.html" %}
+
+
+
+
+
+
+ {% block head %}
+ {% if realm.syndication_enabled %}
+
+ {% endif %}
+ {% endblock %}
+
+ {% block page_title %}{% sitetree_page_title from "main" %}{% endblock %}
+
+
+
+
+ {% block body_start %}{% endblock %}
+
+
+
+
+
+
+ {% sitetree_menu from "main" include "topmenu" template "sitetree/menu_top.html" %}
+
+ {% with template_path="sitetree/menu_top_sub.html" %}
+
+ {% if view.name == 'listing' %}
+ {% sitetree_menu from "main" include "this-children" template template_path %}
+ {% else %}
+ {% sitetree_menu from "main" include "this-siblings" template template_path %}
+ {% endif %}
+
+ {% endwith %}
+
+
+
+
+
+
+
+
+
+
+
+ {% if messages %}
+
+ {% for message in messages %}
+
{{ message }}
+ {% endfor %}
+
+ {% endif %}
+
+ {% block page_contents_pre %}{% endblock %}
+ {% block page_contents %}{% endblock %}
+ {% block page_contents_post %}{% endblock %}
+
+
+ {% cache 21600 nav_bottom %}
+
+ {% endcache %}
+
+
+
+ {% include "sub/static_footer.html" %}
+
+ {% block js_bottom %}{% endblock %}
+
+
+
diff --git a/pythonz/apps/templates/base_details.html b/pythonz/apps/templates/base_details.html
new file mode 100644
index 00000000..4a37424f
--- /dev/null
+++ b/pythonz/apps/templates/base_details.html
@@ -0,0 +1,96 @@
+{% extends "_base.html" %}
+{% load thumbs sitetree siteblocks etc_misc static %}
+{% get_static_prefix as STATIC_URL %}
+
+
+{% block head %}
+ {{ block.super }}
+ {% include "sub/vk_head.html" %}
+
+
+{% endblock %}
+
+{% block page_title %}{{ block.super }} — Про Python{% endblock %}
+
+{% block page_description %}{{ item.get_short_description }}{% endblock %}
+
+{% block page_keywords %}{{ realm.view_listing_keywords }}{% endblock %}
+
+{% block meta_og %}
+
+ {% if item.cover %}
+
+ {% else %}
+
+ {% endif %}
+{% endblock %}
+
+{% block page_contents %}
+
+
+
+ {% block breadcrumbs %}
+ {% sitetree_breadcrumbs from "main" template "sitetree/crumbs.html" %}
+ {% endblock %}
+
+
{% include "sub/box_controls.html" %}
+
+ {% block details_author %}
+
+ {% include "sub/author_editor.html" %}
+
+ {% endblock %}
+
+ {% include "sub/title_block.html" with hide_breadcurmbs=1 %}
+
+ {% block details_contents %}{% endblock %}
+ {% block details_description %}
{{ item.description|urlize|linebreaksbr }}
{% endblock %}
+ {% block details_contents_add %}{% endblock %}
+
+ {% block details_contents_after %}{% endblock %}
+ {% block details_share %}{% include "sub/box_share.html" %}{% endblock %}
+
+ {% include "sub/linked.html" %}
+
+ {% block details_discussions_pre %}
+ {% include "sub/box_recommend.html" with block_id="247489-6" %}
+ {% endblock %}
+ {% block discussions %}
+ {% if item.has_discussions %}
+
+ {% include "sub/comments.html" %}
+
+ {% endif %}
+ {% endblock %}
+
+
+ {% block column_right%}
+
+ {% block column_right_contents %}
+
+
+ {% block cover %}
+
+ {% thumbs_get_thumb_url item.cover 180 236 item.realm as thumb_url %}
+ {% if thumb_url %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% endblock %}
+
+
+ {% block column_controls %}{% include "sub/column_controls.html" %}{% endblock %}
+ {% endblock %}
+
+ {% endblock %}
+
+
+{% endblock %}
+
+
+{% block js_bottom %}
+{{ block.super }}
+
+{% endblock %}
diff --git a/pythonz/apps/templates/base_edit.html b/pythonz/apps/templates/base_edit.html
new file mode 100644
index 00000000..14b3cd88
--- /dev/null
+++ b/pythonz/apps/templates/base_edit.html
@@ -0,0 +1,41 @@
+{% extends "_base.html" %}
+{% load thumbs sitecats %}
+
+{% block head %}
+ {{ form.media }}
+
+
+
+
+{% endblock %}
+
+{% block page_contents %}
+
+ {% include "sub/title_block.html" %}
+
+
+
+
+ {% block hint %}{% endblock %}
+
+
+
+ {% block right_bar %}{% endblock %}
+
+ {% block form %}
+ {{ form }}
+ {% endblock %}
+
+ {% block categories_editor %}
+ {% include "sub/box_tags.html" %}
+ {% endblock %}
+
+{% endblock %}
+
+{% block js_bottom %}
+{{ block.super }}
+
+
+{% endblock %}
diff --git a/pythonz/apps/templates/base_list_item.html b/pythonz/apps/templates/base_list_item.html
new file mode 100644
index 00000000..d8018f81
--- /dev/null
+++ b/pythonz/apps/templates/base_list_item.html
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/pythonz/apps/templates/base_list_items.html b/pythonz/apps/templates/base_list_items.html
new file mode 100644
index 00000000..fc9cb2d6
--- /dev/null
+++ b/pythonz/apps/templates/base_list_items.html
@@ -0,0 +1,7 @@
+{% load thumbs etc_misc %}
+
+
+{% for item in items %}
+ {% include_ "realms/{{ realm.name_plural }}/list_item.html" fallback "base_list_item.html" %}
+{% endfor %}
+
\ No newline at end of file
diff --git a/pythonz/apps/templates/base_listing.html b/pythonz/apps/templates/base_listing.html
new file mode 100644
index 00000000..7fae072c
--- /dev/null
+++ b/pythonz/apps/templates/base_listing.html
@@ -0,0 +1,103 @@
+{% extends "_base.html" %}
+{% load cache etc_misc %}
+
+
+{% block head %}
+ {{ block.super }}
+
+{% endblock %}
+
+{% block page_keywords %}{{ realm.view_listing_keywords }}{% if category %}, {{ category.title }}{% endif %}{% endblock %}
+
+{% block page_description %}{{ realm.view_listing_description }}{% if category %} по теме {{ category.title }}, {{ category.note|truncatechars:80 }}{% endif %}{% endblock %}
+
+{% block page_title %}{{ block.super }} про Python{% endblock %}
+
+{% block page_contents_pre %}
+
+
+
+ {% include "sub/title_block.html" %}
+
+ {% block promo %}
+ {% if category.note %}
+
{{ category.note|urlize }}
+ {% endif %}
+ {% endblock %}
+
+
+
+
+
+
+ {% include "sub/paginator.html" with paginator=items %}
+{% endblock %}
+
+{% block page_contents %}
+ {% include_ "realms/{{ realm.name_plural }}/list_items.html" fallback "base_list_items.html" %}
+{% endblock %}
+
+{% block page_contents_post %}
+ {% include "sub/paginator.html" with paginator=items %}
+
+ {% cache 3600 realmname realm.name %}
+ {% with categories=get_categories %}
+ {% if not category and categories%}
+
+
+ А ещё у нас есть для вас {{ realm.model.get_verbose_name_plural|lower }} в следующих категориях:
+
+ {% for cat in categories %}
+
{{ cat.title }}
+ {% endfor %}…
+
+ {% endif %}
+ {% endwith %}
+ {% endcache %}
+
+
+
+
+
+ {% block ad_rightbar %}
+
+ {% include "sub/box_ads.html" with area="rightbar" %}
+
+
+ {% endblock %}
+
+
+ {% block bar_right %}
+
+
+ Лучшие
+
+
+
+
+
+
+
+ {% block most_voted %}
+ {% if items_most_voted %}
+ {% include_ "realms/{{ realm.name_plural }}/list_items.html" fallback "base_list_items.html" with items=items_most_voted nofade=1 nodecks=1 %}
+ {% else %}
+ Пока этот список не сформирован. Оцените материалы раздела, чтобы исправить ситуацию.
+ {% endif %}
+ {% endblock %}
+
+
+ {% endblock %}
+
+
+
+
+ {% block bar_right_after %}
+
+ {% include "sub/hint.html" %}
+ {% endblock %}
+
+
+
+
+{% endblock %}
diff --git a/pythonz/apps/templates/index.html b/pythonz/apps/templates/index.html
new file mode 100644
index 00000000..95463fe8
--- /dev/null
+++ b/pythonz/apps/templates/index.html
@@ -0,0 +1,42 @@
+{% extends "_base.html" %}
+{% load etc_misc %}
+
+{% block head %}
+ {{ block.super }}
+
+{% endblock %}
+
+{% block page_title %}Про язык Python: справочник, видео, статьи, книги, работа и прочее{% endblock %}
+
+
+{% block page_contents %}
+
+
+
+
+ {% include "sub/search_bar.html" %}
+
+
+
+
+ {% include "sub/box_ads.html" with area="maintop"%}
+
+
+
+
+ {% for realm_data in realms_data %}
+ {% include_ "realms/{{ realm_data.cls.name_plural }}/index_card.html" fallback "sub/realm_card.html" %}
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/apps/details.html b/pythonz/apps/templates/realms/apps/details.html
new file mode 100644
index 00000000..71866130
--- /dev/null
+++ b/pythonz/apps/templates/realms/apps/details.html
@@ -0,0 +1,68 @@
+{% extends "base_details.html" %}
+{% load model_field %}
+
+{% block head %}
+ {{ block.super }}
+ {% include "sub/plotly.html" %}
+{% endblock %}
+
+{% block page_title %}{{ app.title }} на Python{% endblock %}
+
+{% block page_keywords %}{{ block.super }}, python {{ app.title }}{% endblock %}
+
+{% block meta_og %}
+
+
+ {{ block.super }}
+{% endblock %}
+
+
+{% block schema_type %}SoftwareApplication{% endblock %}
+
+{% block details_contents %}
+
+{% endblock %}
+
+{% block details_description %}
+ {{ item.description }}
+{% endblock %}
+
+{% block details_contents_add %}
+ {% if app.slug %}
+
+
+
+
+
+
+
+
+
+ {% with gh_ident=app.github_ident %}{% if gh_ident %}
+
+
+
+ {% endif %}{% endwith %}
+
+
+ {% endif %}
+
+ {% if app.repo %}
+
+ {% model_field_verbose_name from app.repo %}: {{ app.repo }}
+
+ {% endif %}
+
+ {% model_field_verbose_name from app.author %}: {% include "sub/persons_links.html" with persons=app.authors.all %}
+
+
+
+
+
+
+ {% if app.downloads %}
+ {% include "realms/apps/downloads.html" with downloads_map=app.downloads_map %}
+ {% endif %}
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/apps/downloads.html b/pythonz/apps/templates/realms/apps/downloads.html
new file mode 100644
index 00000000..40b4c290
--- /dev/null
+++ b/pythonz/apps/templates/realms/apps/downloads.html
@@ -0,0 +1,37 @@
+
+
+
diff --git a/pythonz/apps/templates/realms/apps/edit.html b/pythonz/apps/templates/realms/apps/edit.html
new file mode 100644
index 00000000..67cb7936
--- /dev/null
+++ b/pythonz/apps/templates/realms/apps/edit.html
@@ -0,0 +1,9 @@
+{% extends "base_edit.html" %}
+
+
+{% block hint %}
+
+ Приложение должно быть свободно распространяемым;
+ Исходный код приложения должен быть открыт.
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/apps/list_item.html b/pythonz/apps/templates/realms/apps/list_item.html
new file mode 100644
index 00000000..b5e89dde
--- /dev/null
+++ b/pythonz/apps/templates/realms/apps/list_item.html
@@ -0,0 +1,5 @@
+{% extends "base_list_item.html" %}
+
+{% block bottom %}
+ {% include "sub/item_info.html" with show_date=0 show_user=0 %}
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/apps/listing.html b/pythonz/apps/templates/realms/apps/listing.html
new file mode 100644
index 00000000..2022576a
--- /dev/null
+++ b/pythonz/apps/templates/realms/apps/listing.html
@@ -0,0 +1,3 @@
+{% extends "base_listing.html" %}
+
+{% block page_title %}Приложения, написанные на Python{% endblock %}
diff --git a/pythonz/apps/templates/realms/articles/details.html b/pythonz/apps/templates/realms/articles/details.html
new file mode 100644
index 00000000..92de8d3d
--- /dev/null
+++ b/pythonz/apps/templates/realms/articles/details.html
@@ -0,0 +1,55 @@
+{% extends "base_details.html" %}
+{% load gravatar model_meta sitetree static %}
+{% get_static_prefix as STATIC_URL %}
+
+{% block head %}
+ {% if article.nofollow %} {% endif %}
+ {{ block.super }}
+ {% include "sub/vk_head.html" %}
+{% endblock %}
+
+{% block meta_og %}
+
+
+ {% if article.published_by_author %}
+
+ {% endif %}
+ {{ block.super }}
+{% endblock %}
+
+{% block schema_type %}Article{% endblock %}
+
+{% block details_contents %}
+
+
+
+{% endblock %}
+
+{% block details_description %}{{ item.description|linebreaksbr }}
{% endblock %}
+
+{% block details_contents_add %}
+ {{ article.text|safe }}
+
+ {% if not article.is_handmade and article.url %}
+
+ {% endif %}
+{% endblock %}
+
+{% block cover %}
+ {% if article.published_by_author %}
+
+
+ {% with article.submitter.get_absolute_url as submitter_url %}
+
{% gravatar_get_img article.submitter 64 %}
+
{{ article.submitter.get_display_name }}
+ {% endwith %}
+
+
+ {% else %}
+ {{ block.super }}
+ {% endif %}
+
+{% endblock %}
diff --git a/apps/templates/realms/articles/edit.html b/pythonz/apps/templates/realms/articles/edit.html
similarity index 57%
rename from apps/templates/realms/articles/edit.html
rename to pythonz/apps/templates/realms/articles/edit.html
index dadea035..ead1c69e 100644
--- a/apps/templates/realms/articles/edit.html
+++ b/pythonz/apps/templates/realms/articles/edit.html
@@ -5,33 +5,35 @@
{{ block.super }}
{% endblock %}
@@ -46,4 +48,4 @@
Не принебрегайте орфографией и синтаксисом;
Жгите!
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/books/details.html b/pythonz/apps/templates/realms/books/details.html
new file mode 100644
index 00000000..aa07c944
--- /dev/null
+++ b/pythonz/apps/templates/realms/books/details.html
@@ -0,0 +1,66 @@
+{% extends "base_details.html" %}
+{% load goodreads model_field %}
+
+
+{% block page_keywords %}{{ block.super }}, {{ book.title }} книга, {{ book.title }} купить{% endblock %}
+
+
+{% block meta_og %}
+
+
+
+
+
+ {{ block.super }}
+{% endblock %}
+
+
+{% block schema_type %}Book{% endblock %}
+
+
+{% block details_contents %}
+
+
+
+ {% model_field_verbose_name from book.author %}: {% include "sub/persons_links.html" with persons=book.authors.all %}
+
+ {% model_field_verbose_name from book.year %}: {{ book.year }}
+ {% if book.isbn or book.isbn_ebook %}
+
+ {% if book.isbn %}
+
+ {% model_field_verbose_name from book.isbn %}: {% goodreads_get_search_tag book.isbn %}
+
+ {% endif %}
+ {% if book.isbn_ebook %}
+
+ {% model_field_verbose_name from book.isbn_ebook %}: {% goodreads_get_search_tag book.isbn_ebook %}
+
+ {% endif %}
+
+ {% endif %}
+ {% if book.translator %}
+ {% model_field_verbose_name from book.translator %}: {{ book.translator }}
+ {% endif %}
+
+
+{% endblock %}
+
+
+{% block details_discussions_pre %}
+ {{ block.super }}
+
+
+
Книга в интернет-магазинах
+
+
+
+
+ Пока вы осматриваетесь, мы пытаемся отыскать для вас эту книгу на полках интернет-магазинов …
+
+
+
+
+
+
+{% endblock %}
diff --git a/apps/templates/realms/books/edit.html b/pythonz/apps/templates/realms/books/edit.html
similarity index 100%
rename from apps/templates/realms/books/edit.html
rename to pythonz/apps/templates/realms/books/edit.html
diff --git a/pythonz/apps/templates/realms/books/index_card.html b/pythonz/apps/templates/realms/books/index_card.html
new file mode 100644
index 00000000..6138e56a
--- /dev/null
+++ b/pythonz/apps/templates/realms/books/index_card.html
@@ -0,0 +1 @@
+{% extends "sub/realm_card_main.html" %}
diff --git a/pythonz/apps/templates/realms/books/list_item.html b/pythonz/apps/templates/realms/books/list_item.html
new file mode 100644
index 00000000..d61450b4
--- /dev/null
+++ b/pythonz/apps/templates/realms/books/list_item.html
@@ -0,0 +1,5 @@
+{% extends "base_list_item.html" %}
+
+{% block bottom %}
+ {% include "sub/item_info.html" with show_date=0 %}
+{% endblock %}
diff --git a/apps/templates/realms/books/partner_links.html b/pythonz/apps/templates/realms/books/partner_links.html
similarity index 84%
rename from apps/templates/realms/books/partner_links.html
rename to pythonz/apps/templates/realms/books/partner_links.html
index 27dfb839..6f9687a3 100644
--- a/apps/templates/realms/books/partner_links.html
+++ b/pythonz/apps/templates/realms/books/partner_links.html
@@ -5,9 +5,9 @@
{% if link.icon_url %}
-
+
{% endif %}
- {{ link.title }}
+ {{ link.title }}
{{ link.price }}
@@ -21,6 +21,8 @@
{% else %}
+
К сожалению отыскать для вас эту книгу на полках интернет-магазинов нам не удалось.
Если вам известно, где её нужно искать, поделитесь этим с нами.
-{% endif %}
\ No newline at end of file
+
+{% endif %}
diff --git a/pythonz/apps/templates/realms/categories/_base.html b/pythonz/apps/templates/realms/categories/_base.html
new file mode 100644
index 00000000..983e0f67
--- /dev/null
+++ b/pythonz/apps/templates/realms/categories/_base.html
@@ -0,0 +1,34 @@
+{% extends "_base.html" %}
+
+{% block page_title %}Материалы про Python, разбитые по категориям{% endblock %}
+
+{% block page_contents %}
+
+
+ {% include "sub/title_block.html" %}
+
+
+
+
+
+ {% block workarea %}{% endblock %}
+
+ {% include "sub/box_recommend.html" with block_id="247489-6" %}
+
+
+
+
+
+ {% include "sub/hint.html" %}
+
+
+ {% block ad_rightbar %}
+
+ {% include "sub/box_ads.html" with area="rightbar" %}
+
+
+ {% endblock %}
+
+
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/categories/details.html b/pythonz/apps/templates/realms/categories/details.html
new file mode 100644
index 00000000..c8910b65
--- /dev/null
+++ b/pythonz/apps/templates/realms/categories/details.html
@@ -0,0 +1,24 @@
+{% extends "realms/categories/_base.html" %}
+{% load sitecats cache %}
+
+{% block page_title %}Материалы про {{ item.title }}{% endblock %}
+{% block page_description %}Здесь перечислены материалы в категории «{{ item.title }}», связанные с Python. {{ item.note|truncatechars:80 }}{% endblock %}
+
+
+{% block page_keywords %}{{ block.super }}, {{ item.title }}{% endblock %}
+
+
+{% block workarea %}
+ {% if item.note %}
+ {{ item.note|urlize }}
+ {% endif %}
+
+ {% cache 3600 category_materials item.pk %}
+ {% if realms_links %}
+ {% include "sub/realms_links_tabs.html" %}
+ {% else %}
+ Пока нет материалов связанных с этой категорией.
+ {% endif %}
+ {% endcache %}
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/categories/listing.html b/pythonz/apps/templates/realms/categories/listing.html
new file mode 100644
index 00000000..80f00cd9
--- /dev/null
+++ b/pythonz/apps/templates/realms/categories/listing.html
@@ -0,0 +1,11 @@
+{% extends "realms/categories/_base.html" %}
+{% load sitecats model_meta %}
+
+
+{% block workarea %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/templates/realms/communities/details.html b/pythonz/apps/templates/realms/communities/details.html
similarity index 57%
rename from apps/templates/realms/communities/details.html
rename to pythonz/apps/templates/realms/communities/details.html
index ba1898e0..1af93b82 100644
--- a/apps/templates/realms/communities/details.html
+++ b/pythonz/apps/templates/realms/communities/details.html
@@ -1,6 +1,9 @@
{% extends "base_details.html" %}
{% load model_field %}
+{% block page_title %}Сообщество {{ community.title }}{% endblock %}
+
+{% block page_keywords %}{{ block.super }}, сообщество {{ community.title }}{% endblock %}
{% block meta_og %}
@@ -8,15 +11,13 @@
{{ block.super }}
{% endblock %}
-
{% block details_contents_add %}
-
- {% if community.url %}
{% endif %}
+
+ {% if community.url %}
{% endif %}
{% if community.place %}
{% endif %}
{% if community.contacts %}
{% model_field_verbose_name from community.contacts %}: {{ community.contacts }}
{% endif %}
-
- {{ community.text|safe }}
-
-{% endblock %}
\ No newline at end of file
+
{{ community.text|safe }}
+
+{% endblock %}
diff --git a/apps/templates/realms/communities/edit.html b/pythonz/apps/templates/realms/communities/edit.html
similarity index 100%
rename from apps/templates/realms/communities/edit.html
rename to pythonz/apps/templates/realms/communities/edit.html
diff --git a/pythonz/apps/templates/realms/communities/list_item.html b/pythonz/apps/templates/realms/communities/list_item.html
new file mode 100644
index 00000000..00df76fa
--- /dev/null
+++ b/pythonz/apps/templates/realms/communities/list_item.html
@@ -0,0 +1,9 @@
+{% extends "base_list_item.html" %}
+
+{% block info %}
{{ item.description }} {% endblock %}
+
+{% block bottom %}
+ {% if item.supporters_num > 0 %}
+
{{ item.supporters_num }}
+ {% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/discussions/details.html b/pythonz/apps/templates/realms/discussions/details.html
new file mode 100644
index 00000000..5728297b
--- /dev/null
+++ b/pythonz/apps/templates/realms/discussions/details.html
@@ -0,0 +1,44 @@
+{% extends "base_details.html" %}
+{% load gravatar model_meta sitetree %}
+
+
+{% block page_description %}Обсуждение «{{ discussion.title }}»{% endblock %}
+
+
+{% block page_keywords %}{{ block.super }}, обсуждение {{ discussion.title }}{% endblock %}
+
+
+{% block head %}
+ {{ block.super }}
+ {% include "sub/vk_head.html" %}
+{% endblock %}
+
+{% block schema_type %}Comment{% endblock %}
+
+{% block details_contents %}
+
+{% endblock %}
+
+{% block details_contents_add %}
+
+ {% if discussion.linked_object %}
+
+ {% endif %}
+
{{ discussion.text|safe }}
+
+{% endblock %}
+
+{% block cover %}
+ {% with discussion.submitter.get_absolute_url as submitter_url %}
+
{% gravatar_get_img discussion.submitter 64 %}
+
{{ discussion.submitter.get_display_name }}
+ {% endwith %}
+{% endblock %}
+
+{% block discussions %}
+
+ {% include "sub/comments.html" with disable_internal=1 author=discussion.submitter entity_name='discussion' entity_id=discussion.id personalized=1 %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/templates/realms/discussions/edit.html b/pythonz/apps/templates/realms/discussions/edit.html
similarity index 100%
rename from apps/templates/realms/discussions/edit.html
rename to pythonz/apps/templates/realms/discussions/edit.html
diff --git a/apps/templates/realms/discussions/list_items.html b/pythonz/apps/templates/realms/discussions/list_items.html
similarity index 68%
rename from apps/templates/realms/discussions/list_items.html
rename to pythonz/apps/templates/realms/discussions/list_items.html
index 3ed46dbe..47166e2e 100644
--- a/apps/templates/realms/discussions/list_items.html
+++ b/pythonz/apps/templates/realms/discussions/list_items.html
@@ -1,3 +1,3 @@
-
+
{% include "realms/discussions/sub_discussions.html" with no_title=1 %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/discussions/sub_discussions.html b/pythonz/apps/templates/realms/discussions/sub_discussions.html
new file mode 100644
index 00000000..d3e079e8
--- /dev/null
+++ b/pythonz/apps/templates/realms/discussions/sub_discussions.html
@@ -0,0 +1,68 @@
+{% load gravatar %}
+
+{% if show_form_for %}
+ {% if request.user.is_authenticated %}
+
+
+
+
+ {% endif %}
+{% endif %}
+
+
+ {% if items %}
+
+
+
+
+ {% else %}
+
+ Нет обсуждений для отображения.
+ {% if not request.user.is_authenticated %}
+
+
+
Если у вас есть, что сказать, можете представиться и исправить ситуацию.
+
+
+ {% endif %}
+
+ {% endif %}
+
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/events/details.html b/pythonz/apps/templates/realms/events/details.html
new file mode 100644
index 00000000..4f94c845
--- /dev/null
+++ b/pythonz/apps/templates/realms/events/details.html
@@ -0,0 +1,116 @@
+{% extends "base_details.html" %}
+{% load model_field %}
+
+
+{% block head %}
+ {{ block.super }}
+ {% include "sub/ya_map_head.html" %}
+{% endblock %}
+
+{% block page_title %}{{ event.page_title }}{% endblock %}
+
+{% block page_description %}Где и когда будет {{ event.page_title }}.{% endblock %}
+
+{% block page_keywords %}{{ block.super }}, событие {{ event.title }}{% endblock %}
+
+{% block meta_og %}
+
+
+ {{ block.super }}
+{% endblock %}
+
+
+{% block schema_type %}Event{% endblock %}
+
+
+{% block details_contents %}
+
+
+ {% if event.fee %}
+ Платно
+ {% elif event.fee is None %}
+ {% else %}
+ Бесплатно
+ {% endif %}
+
+ {% if event.is_in_past %}
+
Событие уже в прошлом
+ {% endif %}
+
+ {% if event.time_start %}
+
+ {{ event.time_start|date:"d E Y года, H:i" }}
+ {% if event.time_finish %}
+ — {{ event.time_finish|date:"d E H:i" }}
+ {% endif %}
+ {% if not event.is_in_past %}
+
+ {% if event.is_now %}[проходит прямо сейчас]{% else %}[{{ event.time_start|timeuntil }} до начала]{% endif %}
+
+ {% endif %}
+ {% endif %}
+
+
+
+
+
+
+ {% if event.place or event.src_place_name %}
+
+ {% model_field_verbose_name from event.place %}:
+
+ {% if event.place %}
+ {{ event.place.title }}
+ {% else %}
+ {{ event.src_place_name }}
+ {% endif %}
+
+
+ {% endif %}
+
+ {% model_field_verbose_name from event.type %}:
+ {{ event.get_display_type }}
+
+
+ {% model_field_verbose_name from event.specialization %}:
+ {{ event.get_display_specialization }}
+
+ {% if event.url %}
+
+ {% model_field_verbose_name from event.url %}:
+ {{ event.url }}
+
+ {% endif %}
+ {% if event.contacts %}
+
+ {% model_field_verbose_name from event.contacts %}
+ {{ event.contacts }}
+
+ {% endif %}
+
+
+ {% if not event.is_now and not event.is_in_past and event.time_start %}
+
+ {% include "sub/ya_forgetmenot.html" %}
+
+ {% endif %}
+
+ {% if event.place %}
+
+ {% include "sub/ya_map.html" with item=event.place element_id="event-map" zoom=5 %}
+ {% endif %}
+
+{% endblock %}
+
+{% block details_description %}
+
+ {% if item.description or event.text %}
+
+
+
{{ item.description|urlize|linebreaksbr }}
+
{{ event.text|safe }}
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/apps/templates/realms/events/edit.html b/pythonz/apps/templates/realms/events/edit.html
similarity index 100%
rename from apps/templates/realms/events/edit.html
rename to pythonz/apps/templates/realms/events/edit.html
diff --git a/pythonz/apps/templates/realms/events/index_card.html b/pythonz/apps/templates/realms/events/index_card.html
new file mode 100644
index 00000000..9831cab4
--- /dev/null
+++ b/pythonz/apps/templates/realms/events/index_card.html
@@ -0,0 +1,8 @@
+{% extends "sub/realm_card_main.html" %}
+
+{% block cover %}
+ {{ block.super }}
+ {% if item.is_now %}
проходит прямо сейчас
{% endif %}
+{% endblock %}
+
+{% block item_title %}{{ item.get_display_type }}: {{ item.title }}{% endblock %}
diff --git a/pythonz/apps/templates/realms/events/list_item.html b/pythonz/apps/templates/realms/events/list_item.html
new file mode 100644
index 00000000..d1d2ef16
--- /dev/null
+++ b/pythonz/apps/templates/realms/events/list_item.html
@@ -0,0 +1,15 @@
+{% extends "base_list_item.html" %}
+
+{% block bottom %}
+
+ {% if item.is_in_past %}
+
В прошлом
+ {% else %}
+ {% if item.time_start %}
+
{{ item.time_start|date:"d M H:i" }} {% if item.time_finish %}— {{ item.time_finish|date:"d M H:i" }}{% endif %}
+ {% endif %}
+ {% endif %}
+
+
{{ item.get_display_type }}
+
{{ item.get_display_specialization }}
+{% endblock %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/events/listing.html b/pythonz/apps/templates/realms/events/listing.html
new file mode 100644
index 00000000..49005b0e
--- /dev/null
+++ b/pythonz/apps/templates/realms/events/listing.html
@@ -0,0 +1,17 @@
+{% extends "base_listing.html" %}
+
+{% block page_title %}Какие конференции Python, встречи, митапы посетить{% endblock %}
+
+{% block bar_right %}
+
+
+
+ Чтобы ваше событие отобразилось здесь, вы можете:
+
+ а. заполнить форму события прямо на нашем сайте,
+ б. зарегистрировать событие
в одном из общих календарей .
+
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/peps/details.html b/pythonz/apps/templates/realms/peps/details.html
new file mode 100644
index 00000000..4dcc285e
--- /dev/null
+++ b/pythonz/apps/templates/realms/peps/details.html
@@ -0,0 +1,85 @@
+{% extends "base_details.html" %}
+{% load model_field %}
+
+{% block page_title %}PEP {{ pep.num }} в Python: {{ pep.title }}{% endblock %}
+
+{% block page_keywords %}{{ block.super }}, предложение по улучшению{% endblock %}
+
+{% block head %}
+ {{ block.super }}
+ {% include "sub/vk_head.html" %}
+{% endblock %}
+
+{% block details_contents %}
+
{{ pep.time_published|date }}
+{% endblock %}
+
+{% block details_description %}
+
+{% endblock %}
+
+{% block details_contents_add %}
+
+
+
+ {% model_field_verbose_name from pep.type %} {{ pep.display_type }}
+
+
+ {% model_field_verbose_name from pep.status %} {{ pep.display_status }}
+
+
+ {% with pep.versions.all as versions %}
+ {% if versions %}
+
+ {% model_field_verbose_name from pep.versions %}
+
+ {% for version in versions %}
+ {{ version.title }}
+ {% endfor %}
+
+
+ {% endif %}
+ {% endwith %}
+
+ {% with pep.authors.all as authors %}
+ {% if authors %}
+
+ {% model_field_verbose_name from pep.authors %}
+ {% include "sub/persons_links.html" with persons=authors %}
+
+ {% endif %}
+ {% endwith %}
+
+
+
+ {% with pep.superseded.all as superseded %}
+ {% if superseded %}
+
+
Предложения, пришедшие на смену данному
+ {% include "realms/peps/list_items.html" with items=superseded %}
+
+ {% endif %}
+ {% endwith %}
+
+ {% with pep.replaces.all as replaces %}
+ {% if replaces %}
+
+
Предложения, которые были заменены данным
+ {% include "realms/peps/list_items.html" with items=replaces %}
+
+ {% endif %}
+ {% endwith %}
+
+ {% with pep.requires.all as requires %}
+ {% if requires %}
+
+
Данное предложение зависит от следующих
+ {% include "realms/peps/list_items.html" with items=requires %}
+
+ {% endif %}
+ {% endwith %}
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/peps/list_items.html b/pythonz/apps/templates/realms/peps/list_items.html
new file mode 100644
index 00000000..7fd8728c
--- /dev/null
+++ b/pythonz/apps/templates/realms/peps/list_items.html
@@ -0,0 +1,20 @@
+
+
+
+ Номер
+ Название
+ Тип
+ Состояние
+
+
+
+{% for pep in items %}
+
+ {{ pep.num }}
+ {{ pep.title }}
+ {{ pep.display_type_letter }}
+ {{ pep.display_status_letter }}
+
+{% endfor %}
+
+
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/peps/listing.html b/pythonz/apps/templates/realms/peps/listing.html
new file mode 100644
index 00000000..d0ab97c3
--- /dev/null
+++ b/pythonz/apps/templates/realms/peps/listing.html
@@ -0,0 +1,43 @@
+{% extends "base_listing.html" %}
+{% load cache %}
+
+{% block page_title %}Все PEP в Python{% endblock %}
+
+{% block bar_right %}
+ {% cache 21600 pep_filter %}
+
+
Легенда
+
+
+
+ Состояние
+
+
+
+ {% for enum, tuple in realm.model.STATUS_MAP.items %}
+
+ {{ tuple.0 }}
+ {{ enum.label }}
+
+ {% endfor %}
+
+
+
+
+ Тип
+
+
+ {% for enum in realm.model.Type %}
+
+ {{ enum.label.0 }}
+ {{ enum.label }}
+
+ {% endfor %}
+
+
+
+
+ {% endcache %}
+
+ {{ block.super }}
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/persons/details.html b/pythonz/apps/templates/realms/persons/details.html
new file mode 100644
index 00000000..fb8803f3
--- /dev/null
+++ b/pythonz/apps/templates/realms/persons/details.html
@@ -0,0 +1,67 @@
+{% extends "base_details.html" %}
+{% load gravatar model_meta model_field sitetree static cache %}
+{% get_static_prefix as STATIC_URL %}
+
+
+{% block page_title %}{{ person.name }} в сообществе Python{% endblock %}
+
+{% block page_description %}{{ person.name }}. Вклад в развитие языка программирования Python.{% endblock %}
+
+{% block page_keywords %}{{ block.super }}, {{ person.name }}, {{ person.name_en }}, {{ person.aka }}{% endblock %}
+
+
+{% block head %}
+ {{ block.super }}
+ {% include "sub/vk_head.html" %}
+ {% include "sub/plotly.html" %}
+{% endblock %}
+
+{% block meta_og %}
+
+
+
+{% endblock %}
+
+
+{% block schema_type %}Person{% endblock %}
+
+
+{% block details_contents %}
+
+ {% if person.user %}
+
+ {% endif %}
+
+
+ {% model_field_verbose_name from person.name %}:
+ {{ person.name }}
+
+
+ {% model_field_verbose_name from person.name_en %}:
+ {{ person.name_en }}
+
+
+ {% model_field_verbose_name from person.aka %}:
+ {{ person.aka }}
+
+
+
+
+
+{% cache 300 person_materials person.pk %}
+ {% if materials.downloads_map %}
+
+
{% include "realms/apps/downloads.html" with downloads_map=materials.downloads_map %}
+
+ {% endif %}
+
+ {% if materials.items %}
+
+ {% include "sub/realms_links_tabs.html" with realms_links=materials.items %}
+
+ {% endif %}
+{% endcache %}
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/persons/list_items.html b/pythonz/apps/templates/realms/persons/list_items.html
new file mode 100644
index 00000000..a394da83
--- /dev/null
+++ b/pythonz/apps/templates/realms/persons/list_items.html
@@ -0,0 +1,10 @@
+
+{% for person in items %}
+
+ {{ person.name }}
+
+ {{ person.name_en }}
+
+
+{% endfor %}
+
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/persons/listing.html b/pythonz/apps/templates/realms/persons/listing.html
new file mode 100644
index 00000000..eb19c4ad
--- /dev/null
+++ b/pythonz/apps/templates/realms/persons/listing.html
@@ -0,0 +1,14 @@
+{% extends "base_listing.html" %}
+
+{% block page_title %}Известные люди в мире Python{% endblock %}
+
+{% block js_bottom %}
+ {{ block.super }}
+
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/places/details.html b/pythonz/apps/templates/realms/places/details.html
new file mode 100644
index 00000000..36b700d2
--- /dev/null
+++ b/pythonz/apps/templates/realms/places/details.html
@@ -0,0 +1,80 @@
+{% extends "base_details.html" %}
+{% load sitecats %}
+
+
+{% block head %}
+ {{ block.super }}
+ {% include "sub/ya_map_head.html" %}
+{% endblock %}
+
+{% block page_title %}Python в городе {{ place.title }}{% endblock %}
+
+{% block page_keywords %}{{ block.super }}, {{ place.title }} python{% endblock %}
+
+{% block schema_type %}AdministrativeArea{% endblock %}
+
+{% block cover %}
+
+ {% include "sub/ya_map.html" with item=item element_id="cover-map" %}
+{% endblock %}
+
+{% block details_contents %}
+
+ {% if allow_im_here %}
+
+ Я здесь!
+
+ {% endif %}
+
+ {{ block.super }}
+{% endblock %}
+
+{% block column_controls %}
+ {% if stats_salary %}
+
+ {% include "realms/vacancies/stats_salary.html" %}
+
+ {% endif %}
+ {{ block.super }}
+{% endblock %}
+
+{% block details_contents_after %}
+
+
+
+ {% if vacancies %}
+ Вакансии
+ {% endif %}
+
+ {% if events %}
+ События
+ {% endif %}
+
+ {% if users %}
+ Пользователи
+ {% endif %}
+
+ {% if communities %}
+ Сообщества
+ {% endif %}
+
+
+
+
{% include "realms/places/sub_realm_links.html" with items=vacancies %}
+
{% include "realms/places/sub_realm_links.html" with items=users %}
+
{% include "realms/places/sub_realm_links.html" with items=communities %}
+
{% include "realms/places/sub_realm_links.html" with items=events %}
+
+
+
+{% endblock %}
+
+{% block js_bottom %}
+ {{ block.super }}
+
+
+{% endblock %}
diff --git a/apps/templates/realms/places/listing.html b/pythonz/apps/templates/realms/places/listing.html
similarity index 56%
rename from apps/templates/realms/places/listing.html
rename to pythonz/apps/templates/realms/places/listing.html
index 92c485fc..ee315352 100644
--- a/apps/templates/realms/places/listing.html
+++ b/pythonz/apps/templates/realms/places/listing.html
@@ -1,23 +1,23 @@
{% extends "_base.html" %}
-
{% block head %}
{{ block.super }}
-
+ {% include "sub/ya_map_head.html" %}
+
{% endblock %}
+{% block page_title %}Места, где используют Python{% endblock %}
{% block page_keywords %}{{ realm.view_listing_keywords }}{% endblock %}
-
{% block page_contents %}
- {% include "sub_title_block.html" %}
+ {% include "sub/title_block.html" %}
-
+
{% if places %}
@@ -37,16 +37,31 @@
new pythonz.Map('map', places);
});
-
- {% for place in places %}
-
- {{ place.title }} {{ place.supporters_num }}
-
- {% endfor %}
-
+
+
+
+ {% for place in places %}
+
+ {{ place.title }} {{ place.supporters_num }}
+
+ {% endfor %}
+
+
+
{% else %}
Как только нам будет, что вам показать, здесь появится карта местности.
{% endif %}
-{% endblock %}
\ No newline at end of file
+{% endblock %}
+
+{% block js_bottom %}
+ {{ block.super }}
+
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/places/sub_realm_links.html b/pythonz/apps/templates/realms/places/sub_realm_links.html
new file mode 100644
index 00000000..b28a2755
--- /dev/null
+++ b/pythonz/apps/templates/realms/places/sub_realm_links.html
@@ -0,0 +1,10 @@
+{% if items %}
+
+ {% for item in items %}
+
+ {{ item.title }}
+ {{ item.description }}
+
+ {% endfor %}
+
+{% endif %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/references/body.html b/pythonz/apps/templates/realms/references/body.html
new file mode 100644
index 00000000..04e453b6
--- /dev/null
+++ b/pythonz/apps/templates/realms/references/body.html
@@ -0,0 +1,33 @@
+{% load text %}
+{% if reference.is_type_callable %}
+
+
+ {% if reference.func_proto %}
+
+
+ {{ reference.func_proto }}
+
+ {% if reference.func_result %} ->
{{ reference.func_result }} {% endif %}
+
+ {% endif %}
+
+ {% if reference.func_params %}
+
+
+ {{ reference.func_params|linebreaksbr }}
+
+
+ {% endif %}
+
+
+
+{% endif %}
+
+
+ {% if ide %}
+ {{ reference.text|nolinks }}
+
+ {% else %}
+ {{ reference.text|safe }}
+ {% endif %}
+
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/references/details.html b/pythonz/apps/templates/realms/references/details.html
new file mode 100644
index 00000000..08558080
--- /dev/null
+++ b/pythonz/apps/templates/realms/references/details.html
@@ -0,0 +1,96 @@
+{% extends "base_details.html" %}
+{% load model_field sitetree static %}
+{% get_static_prefix as STATIC_URL %}
+
+{% block page_title %}{{ reference.title }} в Python{% endblock %}
+
+{% block page_description %}Описание {{ reference.title }} в Python. {{ reference.description|truncatechars:"135" }}{% endblock %}
+
+{% block page_keywords %}{{ block.super }}, {{ reference.page_keywords }}{% endblock %}
+
+{% block js_bottom %}
+ {% include "realms/references/js.html" %}
+{% endblock %}
+
+{% block breadcrumbs %}
+ {% sitetree_breadcrumbs from "references" template "sitetree/crumbs.html" %}
+{% endblock %}
+
+{% block column_right_contents %}
+
+ {% if request.user.is_authenticated %}
+
+
Дополнить справочник:
+
+ {% include "realms/references/quick_add_form.html" with parent=reference.parent_id title='Добавить по соседству' %}
+ {% include "realms/references/quick_add_form.html" with parent=reference.id title='Добавить вложенно' %}
+
+
+
+ {% endif %}
+
+ {% include "sub/column_controls.html" %}
+
+{% endblock %}
+
+{% block schema_type %}APIReference{% endblock %}
+
+{% block details_contents %}
+
+
+
+
+
+ {% if reference.pep %}
+
+ {% endif %}
+ {% if reference.version_added %}
+
+ {% endif %}
+ {% if reference.version_deprecated %}
+
+ {% endif %}
+
+
+{% endblock %}
+
+{% block details_contents_add %}
+
+ {% include "realms/references/body.html" %}
+
+
+ Синонимы поиска: {{ reference.page_keywords }}
+
+
+ {% if children and reference.is_type_bundle %}
+
Статьи раздела
+ {% include "realms/references/sub_list.html" with items=children detailed=True %}
+ {% endif %}
+
+{% endblock %}
+
+{% block details_discussions_pre %}
+
+
+
+ {% if children and not reference.is_type_bundle %}
+
В этом разделе:
+ {% include "realms/references/sub_list.html" with items=children %}
+ {% endif %}
+ {% if reference.parent and siblings %}
+
В разделе «{{ reference.parent.title }}»:
+ {% include "realms/references/sub_list.html" with items=siblings %}
+ {% endif %}
+
+
+
+ {{ block.super }}
+{% endblock %}
+
+{% block cover %}{% endblock %}
diff --git a/pythonz/apps/templates/realms/references/edit.html b/pythonz/apps/templates/realms/references/edit.html
new file mode 100644
index 00000000..2faf07e8
--- /dev/null
+++ b/pythonz/apps/templates/realms/references/edit.html
@@ -0,0 +1,39 @@
+{% extends "base_edit.html" %}
+
+
+{% block head %}
+ {{ block.super }}
+
+{% endblock %}
+
+
+{% block hint %}
+
+ Заполняйте форму аккуратно;
+ Обратите особое внимание на правильность заполнения поля «Тип статьи».
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/references/ide.html b/pythonz/apps/templates/realms/references/ide.html
new file mode 100644
index 00000000..7730e37f
--- /dev/null
+++ b/pythonz/apps/templates/realms/references/ide.html
@@ -0,0 +1,49 @@
+
+
+
+
+
pythonz IDE hint — {{ term }}
+ {% include "sub/static_base.html" %}
+
+
+
+
+ {% if not results %}
+ {% if error %}
+
{{ error }}
+ {% endif %}
+
Поиск не дал результатов.
+ {% else %}
+ {% for reference in results %}
+
+
+
+
+
{{ reference.description }}
+ {% include "realms/references/body.html" with ide=1 %}
+
+
+
+ {% endfor %}
+ {% endif %}
+
+
+
+
+ {% include "sub/box_ads.html" with area="ide" %}
+
+
+ {% include "sub/static_footer.html" %}
+ {% include "realms/references/js.html" %}
+
+
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/references/js.html b/pythonz/apps/templates/realms/references/js.html
new file mode 100644
index 00000000..422d4bd9
--- /dev/null
+++ b/pythonz/apps/templates/realms/references/js.html
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/apps/templates/realms/references/quick_add_form.html b/pythonz/apps/templates/realms/references/quick_add_form.html
similarity index 75%
rename from apps/templates/realms/references/quick_add_form.html
rename to pythonz/apps/templates/realms/references/quick_add_form.html
index c0fb5770..e7375945 100644
--- a/apps/templates/realms/references/quick_add_form.html
+++ b/pythonz/apps/templates/realms/references/quick_add_form.html
@@ -1,6 +1,6 @@
-
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/references/sub_list.html b/pythonz/apps/templates/realms/references/sub_list.html
new file mode 100644
index 00000000..459ad1c5
--- /dev/null
+++ b/pythonz/apps/templates/realms/references/sub_list.html
@@ -0,0 +1,33 @@
+{% if detailed %}
+
+
+ {% for item in items %}
+
+
+ {% with item.version_deprecated as del %}
+ {% if del %}{% endif %}
+
+ {{ item.title }}
+
+ {% if del %}{% endif %}
+ {% endwith %}
+
+ {{ item.description|truncatechars:70 }}
+
+ {% endfor %}
+
+
+{% else %}
+
+
+ {% for item in items %}
+
+ {% with item.version_deprecated as del %}
+ {% if del %}{% endif %}
+ {{ item.title }}
+ {% if del %}{% endif %}
+ {% endwith %}
+
+ {% endfor %}
+
+{% endif %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/users/details.html b/pythonz/apps/templates/realms/users/details.html
new file mode 100644
index 00000000..53e2e6ea
--- /dev/null
+++ b/pythonz/apps/templates/realms/users/details.html
@@ -0,0 +1,155 @@
+{% extends "base_details.html" %}
+{% load gravatar sitetree model_field cache %}
+
+
+{% block page_title %}{{ user.title }} на pythonz.net{% endblock %}
+
+{% block page_description %}Профиль питониста «{{ user.title }}»{% endblock %}
+
+{% block page_keywords %}{{ block.super }}, {{ user.title }} python{% endblock %}
+
+{% block meta_og %}
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block schema_type %}Person{% endblock %}
+
+{% block details_contents %}
+
Общая информация
+
+
+
+ {% model_field_verbose_name from user.username %}:
+ {{ user.get_username_partial }}
+
+
+ {% model_field_verbose_name from user.date_joined %}:
+ {{ user.date_joined }}
+
+
+
+
+
+
+ {% if user.place %}
+
+ {% model_field_verbose_name from user.place %}:
+ {{ user.place.title }}
+
+ {% endif %}
+ {% if user.email_public %}
+
+ {% model_field_verbose_name from user.email_public %}:
+ {{ user.email_public }}
+
+ {% endif %}
+ {% if user.karma > 0 %}
+ {% if user.url %}
+
+ {% model_field_verbose_name from user.url %}:
+ {{ user.url }}
+
+ {% endif %}
+ {% endif %}
+ {% if user.person %}
+
+
+
+
+
+ {% endif %}
+
+
+
+{% cache 300 user_stats user.pk %}
+ {% if stats %}
+
+
+
Вклад в сообщество
+
+
+
+
+ {% for realm_name, stats in stats.items %}
+
+ {{ realm_name }}
+ {{ stats.cnt_published }}
+
+ {% if stats.cnt_postponed %}
+ +{{ stats.cnt_postponed }}
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+ {% endif %}
+{% endcache %}
+
+ {% if drafts %}
+
+
+
Неопубликованные
+
+ {% for realm_name, drafts in drafts.items %}
+
{{ realm_name }} {{ drafts|length }}
+
+ {% for draft in drafts %}
+
+
+ {% if draft.is_draft %}
+
+ {% else %}
+
+ {% endif %}
+
+ {{ draft.time_created|date }}
+ {{ draft.title }}
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if bookmarks %}
+
+
+
Избранное
+ {% for realm_model, bookmark_list in bookmarks.items %}
+ {% if bookmark_list %}
+
+
{{ realm_model.get_verbose_name_plural }}
+
+
+ {% endif %}
+ {% endfor %}
+
+ {% endif %}
+
+{% endblock %}
+
+{% block details_share %}{% endblock %}
+
+{% block column_controls %}{% endblock %}
+
+{% block cover %}
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/users/edit.html b/pythonz/apps/templates/realms/users/edit.html
new file mode 100644
index 00000000..08647fee
--- /dev/null
+++ b/pythonz/apps/templates/realms/users/edit.html
@@ -0,0 +1,22 @@
+{% extends "base_edit.html" %}
+{% load sitemessage %}
+
+{% block hint %}
+
+ Аватары берутся с сервиса Gravatar и привязаны к адресу электронной почты;
+
+{% endblock %}
+
+
+{% block form %}
+
Подписка
+
+
+ {{ block.super }}
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/users/list_item.html b/pythonz/apps/templates/realms/users/list_item.html
new file mode 100644
index 00000000..8e87c0b2
--- /dev/null
+++ b/pythonz/apps/templates/realms/users/list_item.html
@@ -0,0 +1,12 @@
+{% extends "base_list_item.html" %}
+{% load gravatar %}
+
+{% block cover %}
{% endblock %}
+
+{% block info %}
{{ item.username }} {% endblock %}
+
+{% block bottom %}
+ {% if item.supporters_num > 0 %}
+
{{ item.supporters_num }}
+ {% endif %}
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/users/listing.html b/pythonz/apps/templates/realms/users/listing.html
new file mode 100644
index 00000000..5783344e
--- /dev/null
+++ b/pythonz/apps/templates/realms/users/listing.html
@@ -0,0 +1,5 @@
+{% extends "base_listing.html" %}
+
+{% block page_title %}Пользователи pythonz.net{% endblock %}
+
+{% block bar_right %}{% endblock %}
diff --git a/pythonz/apps/templates/realms/vacancies/list_item.html b/pythonz/apps/templates/realms/vacancies/list_item.html
new file mode 100644
index 00000000..015a09e5
--- /dev/null
+++ b/pythonz/apps/templates/realms/vacancies/list_item.html
@@ -0,0 +1,27 @@
+
+
+ {% include "sub/item_cover.html" with thumb_url=item.cover %}
+
+
+
+
+
diff --git a/pythonz/apps/templates/realms/vacancies/listing.html b/pythonz/apps/templates/realms/vacancies/listing.html
new file mode 100644
index 00000000..23a9584e
--- /dev/null
+++ b/pythonz/apps/templates/realms/vacancies/listing.html
@@ -0,0 +1,53 @@
+{% extends "base_listing.html" %}
+
+{% block page_title %}Работа для разработчиков Python{% endblock %}
+
+{% block bar_right %}
+
+
+
+
+ Вы можете опубликовать вакансию на
hh.ru ,
+ и, если она имеет отношение к Питону, мы покажем её здесь автоматически.
+
+
+
+
+
Статистика
+
+
+
+
+
+
+
+
+ {% include "realms/vacancies/stats_salary.html" %}
+
+ {% if stats_places %}
+
Места
+
+
+ {% for stat_row in stats_places %}
+
+
+ {% if stat_row.is_published %}
+ {{ stat_row.title }}
+ {% else %}
+ {{ stat_row.title }}
+ {% endif %}
+
+ {{ stat_row.vacancies_count }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/apps/templates/realms/vacancies/stats_salary.html b/pythonz/apps/templates/realms/vacancies/stats_salary.html
similarity index 55%
rename from apps/templates/realms/vacancies/stats_salary.html
rename to pythonz/apps/templates/realms/vacancies/stats_salary.html
index 06539784..688e2daa 100644
--- a/apps/templates/realms/vacancies/stats_salary.html
+++ b/pythonz/apps/templates/realms/vacancies/stats_salary.html
@@ -1,5 +1,5 @@
{% if stats_salary %}
-
Зарплата
+
Зарплата
@@ -10,12 +10,12 @@ Зарплата
- {% for stat_row in stats_salary %}
+ {% for currency, stat in stats_salary.items %}
- {{ stat_row.salary_currency }}
- {{ stat_row.min }}
- {{ stat_row.avg }}
- {{ stat_row.max }}
+ {{ currency }}
+ {{ stat.min }}
+ {{ stat.avg }}
+ {{ stat.max }}
{% endfor %}
diff --git a/pythonz/apps/templates/realms/versions/details.html b/pythonz/apps/templates/realms/versions/details.html
new file mode 100644
index 00000000..da704cbb
--- /dev/null
+++ b/pythonz/apps/templates/realms/versions/details.html
@@ -0,0 +1,50 @@
+{% extends "base_details.html" %}
+
+
+{% block page_title %}Что нового в Python {{ version.title }}{% endblock %}
+
+{% block page_keywords %}{{ block.super }}, python версия {{ version.title }}{% endblock %}
+
+{% block head %}
+ {{ block.super }}
+ {% include "sub/vk_head.html" %}
+{% endblock %}
+
+{% block details_contents %}
+ Дата выпуска: {{ version.date }}
+{% endblock %}
+
+{% block details_contents_add %}
+
+ {{ version.text|safe }}
+
+ {% if added %}
+
+ {% endif %}
+
+ {% if deprecated %}
+
+ {% endif %}
+
+ {% if peps %}
+
+
Предложения по улучшению (PEP)
+ {% include "realms/peps/list_items.html" with items=peps %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/versions/edit.html b/pythonz/apps/templates/realms/versions/edit.html
new file mode 100644
index 00000000..d7e63024
--- /dev/null
+++ b/pythonz/apps/templates/realms/versions/edit.html
@@ -0,0 +1,9 @@
+{% extends "base_edit.html" %}
+
+
+{% block hint %}
+
+ Убедитесь, что версия не была добавлена ранее;
+ Добавляйте только существующие, либо уже запланированные версии.
+
+{% endblock %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/versions/lifetime.html b/pythonz/apps/templates/realms/versions/lifetime.html
new file mode 100644
index 00000000..d5e35fac
--- /dev/null
+++ b/pythonz/apps/templates/realms/versions/lifetime.html
@@ -0,0 +1,70 @@
+
+
+
diff --git a/pythonz/apps/templates/realms/versions/list_item.html b/pythonz/apps/templates/realms/versions/list_item.html
new file mode 100644
index 00000000..2a720e70
--- /dev/null
+++ b/pythonz/apps/templates/realms/versions/list_item.html
@@ -0,0 +1,10 @@
+{% extends "base_list_item.html" %}
+
+{% block info %}{% if item.current %}Актуальная
{% endif %}{% endblock %}
+
+{% block bottom %}
+ {{ item.date }}
+ {% if item.supporters_num > 0 %}
+ {{ item.supporters_num }}
+ {% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/realms/versions/listing.html b/pythonz/apps/templates/realms/versions/listing.html
new file mode 100644
index 00000000..5d591464
--- /dev/null
+++ b/pythonz/apps/templates/realms/versions/listing.html
@@ -0,0 +1,13 @@
+{% extends "base_listing.html" %}
+
+{% block head %}
+ {{ block.super }}
+ {% include "sub/plotly.html" %}
+{% endblock %}
+
+{% block page_title %}Версии Python и срок поддержки{% endblock %}
+
+{% block page_contents_pre %}
+ {{ block.super }}
+ {% include "realms/versions/lifetime.html" %}
+{% endblock %}
diff --git a/pythonz/apps/templates/realms/videos/details.html b/pythonz/apps/templates/realms/videos/details.html
new file mode 100644
index 00000000..40f86d4e
--- /dev/null
+++ b/pythonz/apps/templates/realms/videos/details.html
@@ -0,0 +1,39 @@
+{% extends "base_details.html" %}
+{% load model_field thumbs etc_misc %}
+
+
+{% block page_keywords %}{{ block.super }}, {{ video.title }} видео, {{ video.title }} смотреть{% endblock %}
+
+
+{% block meta_og %}
+
+
+
+
+ {{ block.super }}
+{% endblock %}
+
+
+{% block schema_type %}VideoObject{% endblock %}
+
+
+{% block details_contents %}
+
+
+
+
+ {{ video.code|safe }}
+
+
+
+
+ {% model_field_verbose_name from video.year %}: {{ video.year }}
+
+ {% model_field_verbose_name from video.author %}: {% include "sub/persons_links.html" with persons=video.authors.all %}
+
+ {% if video.translator %}
+ {% model_field_verbose_name from video.translator %}: {{ video.translator }}
+ {% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/templates/realms/videos/edit.html b/pythonz/apps/templates/realms/videos/edit.html
similarity index 100%
rename from apps/templates/realms/videos/edit.html
rename to pythonz/apps/templates/realms/videos/edit.html
diff --git a/pythonz/apps/templates/realms/videos/index_card.html b/pythonz/apps/templates/realms/videos/index_card.html
new file mode 100644
index 00000000..0a71aaa2
--- /dev/null
+++ b/pythonz/apps/templates/realms/videos/index_card.html
@@ -0,0 +1,7 @@
+{% extends "sub/realm_card_main.html" %}
+
+{% block cover %}
+
+ {{ item.code|safe }}
+
+{% endblock %}
diff --git a/apps/templates/sitemessage/messages/base.html b/pythonz/apps/templates/sitemessage/messages/base.html
similarity index 100%
rename from apps/templates/sitemessage/messages/base.html
rename to pythonz/apps/templates/sitemessage/messages/base.html
diff --git a/apps/templates/sitemessage/messages/digest__smtp.html b/pythonz/apps/templates/sitemessage/messages/digest__smtp.html
similarity index 91%
rename from apps/templates/sitemessage/messages/digest__smtp.html
rename to pythonz/apps/templates/sitemessage/messages/digest__smtp.html
index 04a290e2..2d4094ca 100644
--- a/apps/templates/sitemessage/messages/digest__smtp.html
+++ b/pythonz/apps/templates/sitemessage/messages/digest__smtp.html
@@ -10,7 +10,7 @@ {{ realm_name }}
{% for entry in entries %}
{{ entry.title }}
- {{ entry.description }}
+ {{ entry.get_description|safe }}
{% endfor %}
diff --git a/pythonz/apps/templates/sitemessage/messages/new_entity__smtp.html b/pythonz/apps/templates/sitemessage/messages/new_entity__smtp.html
new file mode 100644
index 00000000..4ffe22fe
--- /dev/null
+++ b/pythonz/apps/templates/sitemessage/messages/new_entity__smtp.html
@@ -0,0 +1,7 @@
+{% extends "sitemessage/messages/base.html" %}
+{% load etc_misc %}
+
+
+{% block contents %}
+ Новое на сайте: {{ entity_title }} .
+{% endblock %}
diff --git a/pythonz/apps/templates/sitemessage/messages/simple__smtp.html b/pythonz/apps/templates/sitemessage/messages/simple__smtp.html
new file mode 100644
index 00000000..756671aa
--- /dev/null
+++ b/pythonz/apps/templates/sitemessage/messages/simple__smtp.html
@@ -0,0 +1,9 @@
+{% extends "sitemessage/messages/base.html" %}
+
+{% block contents %}
+ {% if contents %}
+ {{ contents|safe|linebreaksbr|urlize }}
+ {% else %}
+ {{ text|safe }}
+ {% endif %}
+{% endblock %}
diff --git a/pythonz/apps/templates/sitetree/crumbs.html b/pythonz/apps/templates/sitetree/crumbs.html
new file mode 100644
index 00000000..366b15f4
--- /dev/null
+++ b/pythonz/apps/templates/sitetree/crumbs.html
@@ -0,0 +1,22 @@
+{% load sitetree %}
+{% if sitetree_items|length == 1 %}
+{% else %}
+
+
+ {% for item in sitetree_items %}
+ {% if not forloop.last %}
+
+
+ {{ item.title_resolved }}
+
+
+ {% else %}
+
+ {{ item.title_resolved }}
+
+
+ {% endif %}
+ {% endfor %}
+
+
+{% endif %}
diff --git a/pythonz/apps/templates/sitetree/menu_dropdown.html b/pythonz/apps/templates/sitetree/menu_dropdown.html
new file mode 100644
index 00000000..8f14e20a
--- /dev/null
+++ b/pythonz/apps/templates/sitetree/menu_dropdown.html
@@ -0,0 +1,6 @@
+{% load sitetree %}
+
\ No newline at end of file
diff --git a/apps/templates/sitetree/menu_footer.html b/pythonz/apps/templates/sitetree/menu_footer.html
similarity index 100%
rename from apps/templates/sitetree/menu_footer.html
rename to pythonz/apps/templates/sitetree/menu_footer.html
diff --git a/pythonz/apps/templates/sitetree/menu_top.html b/pythonz/apps/templates/sitetree/menu_top.html
new file mode 100644
index 00000000..6a8295bf
--- /dev/null
+++ b/pythonz/apps/templates/sitetree/menu_top.html
@@ -0,0 +1,26 @@
+{% load sitetree %}
+
\ No newline at end of file
diff --git a/pythonz/apps/templates/sitetree/menu_top_sub.html b/pythonz/apps/templates/sitetree/menu_top_sub.html
new file mode 100644
index 00000000..e177814a
--- /dev/null
+++ b/pythonz/apps/templates/sitetree/menu_top_sub.html
@@ -0,0 +1,8 @@
+{% load sitetree %}
+
\ No newline at end of file
diff --git a/apps/templates/static/403.html b/pythonz/apps/templates/static/403.html
similarity index 100%
rename from apps/templates/static/403.html
rename to pythonz/apps/templates/static/403.html
diff --git a/apps/templates/static/404.html b/pythonz/apps/templates/static/404.html
similarity index 51%
rename from apps/templates/static/404.html
rename to pythonz/apps/templates/static/404.html
index 762ba268..03d14c65 100644
--- a/apps/templates/static/404.html
+++ b/pythonz/apps/templates/static/404.html
@@ -8,7 +8,7 @@
{% block body %}
- Мы не можем показать вам запрошенную вами страницу и потому показываем эту.
- Возможно запрашиваемая страница была удалена, или перемещена, или вовсе не существовала.
- Удостоверьтесь в правильности ссылки, попробуйте воспользоваться поиском.
+ Мы не можем показать вам запрошенную вами страницу и потому показываем эту.
+ Возможно запрашиваемая страница была удалена, или перемещена, или вовсе не существовала.
+ Удостоверьтесь в правильности ссылки, попробуйте воспользоваться поиском.
{% endblock %}
diff --git a/pythonz/apps/templates/static/500.html b/pythonz/apps/templates/static/500.html
new file mode 100644
index 00000000..5725c734
--- /dev/null
+++ b/pythonz/apps/templates/static/500.html
@@ -0,0 +1,14 @@
+{% extends "static/_base.html" %}
+
+
+{% block page_title %}{{ block.super }}500 Ошибка сервера{% endblock %}
+
+
+{% block heading %}500 Ошибка сервера{% endblock %}
+
+
+{% block body %}
+ Похоже, что у нас проблемы. На сервере произошла ошибка.
+ Скорее всего мы уже знаем об этом и вероятно даже занимаемся решеним проблемы.
+ Вы же можете попробовать повторить запрос немного позже.
+{% endblock %}
diff --git a/pythonz/apps/templates/static/_base.html b/pythonz/apps/templates/static/_base.html
new file mode 100644
index 00000000..0647b5c9
--- /dev/null
+++ b/pythonz/apps/templates/static/_base.html
@@ -0,0 +1,33 @@
+{% extends "_base.html" %}
+
+{% block page_description %}{% endblock %}
+
+{% block page_title %}на pythonz.net{% endblock %}
+
+{% block page_contents %}
+
+ {% block heading_wrap %}
+
+ {% endblock %}
+
+
+
+
+ {% block body %}{% endblock %}
+
+
+
+ {% block ads %}
+ {% include "sub/box_ads.html" with area="rightbar" %}
+ {% endblock %}
+
+
+
+
+
+
+ {% block zen %}{% include "sub/zen.html" %}{% endblock %}
+
+{% endblock %}
diff --git a/apps/templates/static/_base_sitetreed.html b/pythonz/apps/templates/static/_base_sitetreed.html
similarity index 68%
rename from apps/templates/static/_base_sitetreed.html
rename to pythonz/apps/templates/static/_base_sitetreed.html
index 3a1384cf..2210829d 100644
--- a/apps/templates/static/_base_sitetreed.html
+++ b/pythonz/apps/templates/static/_base_sitetreed.html
@@ -1,11 +1,6 @@
{% extends "static/_base.html" %}
{% load sitetree %}
-
-{% block page_title %}{{ block.super }}{% sitetree_page_title from "about" %}{% endblock %}
-
-
+{% block page_title %}{% sitetree_page_title from "about" %} {{ block.super }}{% endblock %}
{% block page_description %}{% sitetree_page_description from "about" %}{% endblock %}
-
-
{% block heading %}{% sitetree_page_title from "about" %}{% endblock %}
diff --git a/pythonz/apps/templates/static/about.html b/pythonz/apps/templates/static/about.html
new file mode 100644
index 00000000..0bd7ecdd
--- /dev/null
+++ b/pythonz/apps/templates/static/about.html
@@ -0,0 +1,65 @@
+{% extends "static/_base_sitetreed.html" %}
+
+{% block page_title %}О проекте pythonz.net{% endblock %}
+
+{% block body %}
+
+
+ Это площадка для русскоязычного сообщества людей, создающих приложения при помощи
+ языка программирования Python, и попытка заинтересовать тех, кто ещё не знаком с ним.
+ Приходите, приводите друзей.
+
+
+
+
+
+
+
Посетителям
+
+ Регистрируйтесь, оценивайте материалы, добавляйте новые, актуализируйте информацию в имеющихся, принимайте участие в обсуждениях.
+
+
+
+
+
Разработчикам
+
+ Проект разрабатывается открыто: вы можете посетить страницу проекта на GitHub ,
+ ознакомится с кодом и поучаствовать в развитии — поделиться идеей, задать вопрос, расширить функциональность, исправить ошибку.
+
+
+
+
+
Безопасность
+
+ В случае обнаружения проблемы безопасности в проекте, пожалуйста, сообщите о ней
+ на адрес security@pythonz.net до обнародования.
+
+
+ В письме желательно указать на место в коде, ответственное за уязвимость. Хорошо, если вы также приведёте пример эксплуатации найденной уязвимости.
+
+
+
+
+
Про вандализм
+
+ Предполагается , что посетители сайта заинтересованы в конструктивном общении и поддержании благожелательной атмосферы.
+
+
+ Поэтому к совершающим любые деструктивные действия на площадке сайта и связанных проектов
+ будут, при обнаружении факта упомянутых действий, без дополнительных предупреждений применены меры
+ в виде ограничения прав доступа к упомянутым здесь ресурсам и/или их функциональности.
+
+
+ К деструктивным действиям можно отнести, например: использование нецензурной лексики,
+ оскорбление других пользователей, порча опубликованных материалов.
+
+
+
+
+
+
+
+{% endblock %}
+
+
+{% block zen %}{% endblock %}
diff --git a/apps/templates/static/login.html b/pythonz/apps/templates/static/login.html
similarity index 60%
rename from apps/templates/static/login.html
rename to pythonz/apps/templates/static/login.html
index bfcf7d4a..4637705a 100644
--- a/apps/templates/static/login.html
+++ b/pythonz/apps/templates/static/login.html
@@ -9,15 +9,10 @@
{% block body %}
-
-
+
+
{% sitegate_signin_form %}
-
-
-
- {% sitegate_signup_form %}
-
{% endblock %}
diff --git a/pythonz/apps/templates/static/promo.html b/pythonz/apps/templates/static/promo.html
new file mode 100644
index 00000000..d42500f9
--- /dev/null
+++ b/pythonz/apps/templates/static/promo.html
@@ -0,0 +1,122 @@
+{% extends "static/_base_sitetreed.html" %}
+{% load sitetree %}
+
+
+{% block page_contents %}
+
+
+
+
+ Python — высокоуровневый язык программирования с динамической типизацией, поддерживающий объектно-ориентированный,
+ функциональный и императивный стили программирования. Это язык общего назначения, на котором можно
+ одинаково успешно разрабатывать системные приложения с графическим интерфейсом, утилиты командной строки, научные приложения, игры, приложения для веб и много другое.
+
+
+
+
+
+
+
+ Если вы используете операционные системы семейств Linux, Unix, или OS X, то Python у вас уже установлен.
+ В остальных случаях, можете скачать установочный пакет или архив с исходным кодом нужной версии из раздела Версии Python .
+
+
Откройте окно терминала, наберите python, нажмите клавишу Enter , чтобы перейти в интерактивный режим работы интерпретатора и узнать, какая версия Питона установлена:
+
+$ python3
+
+Python 3.13.3 (main, Jun 16 2025, 18:15:32) [GCC 14.2.0] on linux
+Type "help", "copyright", "credits" or "license" for more information.
+>>>
+
+
Нажмите Ctrl+D , чтобы покинуть интерактивный режим.
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/static/search.html b/pythonz/apps/templates/static/search.html
new file mode 100644
index 00000000..b9035c0f
--- /dev/null
+++ b/pythonz/apps/templates/static/search.html
@@ -0,0 +1,27 @@
+{% extends "static/_base_sitetreed.html" %}
+
+{% block body %}
+
{% include "sub/box_ads.html" with area="searchtop" %}
+
Количество результатов: {{ results_len }}
+
+ {% if results_len %}
+
+ {% for result in results %}
+
+
+ {{ result.description }}
+
+ {% endfor %}
+
+ {% else %}
+
К сожалению, ничего не найдено.
+ {% endif %}
+
+
{% include "sub/box_ads.html" with area="searchbottom" %}
+{% endblock %}
+
+{% block zen %}{% endblock %}
+
+{% block ads %}
+ {% include "sub/box_ads.html" with area="rightbar" type="search" %}
+{% endblock %}
diff --git a/apps/templates/static/search_site.html b/pythonz/apps/templates/static/search_site.html
similarity index 58%
rename from apps/templates/static/search_site.html
rename to pythonz/apps/templates/static/search_site.html
index f751ac77..21f29775 100644
--- a/apps/templates/static/search_site.html
+++ b/pythonz/apps/templates/static/search_site.html
@@ -1,7 +1,17 @@
{% extends "static/_base_sitetreed.html" %}
{% block body %}
- {% include "sub_search_bar.html" %}
+
{% include "sub/box_ads.html" with area="searchtop" %}
+
+ {% include "sub/search_bar.html" %}
-{% endblock %}
\ No newline at end of file
+
+
{% include "sub/box_ads.html" with area="searchbottom" %}
+{% endblock %}
+
+{% block zen %}{% endblock %}
+
+{% block ads %}
+ {% include "sub/box_ads.html" with area="rightbar" type="search" %}
+{% endblock %}
diff --git a/apps/templates/static/sitemap.html b/pythonz/apps/templates/static/sitemap.html
similarity index 100%
rename from apps/templates/static/sitemap.html
rename to pythonz/apps/templates/static/sitemap.html
diff --git a/pythonz/apps/templates/sub/author_editor.html b/pythonz/apps/templates/sub/author_editor.html
new file mode 100644
index 00000000..aed99725
--- /dev/null
+++ b/pythonz/apps/templates/sub/author_editor.html
@@ -0,0 +1,12 @@
+
diff --git a/pythonz/apps/templates/sub/box_ads.html b/pythonz/apps/templates/sub/box_ads.html
new file mode 100644
index 00000000..a0f5b591
--- /dev/null
+++ b/pythonz/apps/templates/sub/box_ads.html
@@ -0,0 +1,82 @@
+{% if area == "rightbar" %}
+
+
+ {% if type == "details" %}
+
+
{% include "sub/box_ads_stub.html" %}
+
+
+ {% elif type == "search" %}
+
+
{% include "sub/box_ads_stub.html" %}
+
+
+ {% else %}
+
+
{% include "sub/box_ads_stub.html" %}
+
+
+ {% endif %}
+
+
+{% elif area == "searchtop" %}
+
+
{% include "sub/box_ads_stub.html" %}
+
+
+{% elif area == "searchbottom" %}
+
+
{% include "sub/box_ads_stub.html" %}
+
+
+{% elif area == "ide" %}
+
+
{% include "sub/box_ads_stub.html" %}
+
+
+
+{% elif area == "maintop" %}
+
+
{% include "sub/box_ads_stub.html" %}
+
+
+{% endif %}
diff --git a/pythonz/apps/templates/sub/box_ads_stub.html b/pythonz/apps/templates/sub/box_ads_stub.html
new file mode 100644
index 00000000..e9f2a0c1
--- /dev/null
+++ b/pythonz/apps/templates/sub/box_ads_stub.html
@@ -0,0 +1,5 @@
+{% if debug %}
+
+ место для навязчивой рекламы
+
+{% endif %}
diff --git a/pythonz/apps/templates/sub/box_bookmark.html b/pythonz/apps/templates/sub/box_bookmark.html
new file mode 100644
index 00000000..44b9921f
--- /dev/null
+++ b/pythonz/apps/templates/sub/box_bookmark.html
@@ -0,0 +1,14 @@
+{% load model_meta %}
+
+ {% if request.user.is_authenticated %}
+ {% if item.is_bookmarked %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% else %}
+
+
+ {% endif %}
+
diff --git a/pythonz/apps/templates/sub/box_controls.html b/pythonz/apps/templates/sub/box_controls.html
new file mode 100644
index 00000000..6ef3d541
--- /dev/null
+++ b/pythonz/apps/templates/sub/box_controls.html
@@ -0,0 +1,5 @@
+{% if item_edit_allowed %}
+
+{% endif %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/sub/box_rating.html b/pythonz/apps/templates/sub/box_rating.html
new file mode 100644
index 00000000..024b2b72
--- /dev/null
+++ b/pythonz/apps/templates/sub/box_rating.html
@@ -0,0 +1,18 @@
+
+ {% if request.user.is_authenticated %}
+ {% if item.my_support %}
+
+ {{ item.supporters_num }}
+
+
+ {% else %}
+
+ {{ item.supporters_num }}
+
+ {% endif %}
+ {% else %}
+
+ {{ item.supporters_num }}
+
+ {% endif %}
+
diff --git a/pythonz/apps/templates/sub/box_recommend.html b/pythonz/apps/templates/sub/box_recommend.html
new file mode 100644
index 00000000..13cf16ec
--- /dev/null
+++ b/pythonz/apps/templates/sub/box_recommend.html
@@ -0,0 +1,8 @@
+
+
+
{% include "sub/box_ads_stub.html" %}
diff --git a/pythonz/apps/templates/sub/box_share.html b/pythonz/apps/templates/sub/box_share.html
new file mode 100644
index 00000000..3a7e19c5
--- /dev/null
+++ b/pythonz/apps/templates/sub/box_share.html
@@ -0,0 +1,15 @@
+
+
+ {% if item_rating_allowed %}
+ {% include "sub/box_rating.html" %}
+ {% endif %}
+
+ {% include "sub/box_bookmark.html" %}
+
+
+
+
+
+
diff --git a/apps/templates/sub_box_tags.html b/pythonz/apps/templates/sub/box_tags.html
similarity index 81%
rename from apps/templates/sub_box_tags.html
rename to pythonz/apps/templates/sub/box_tags.html
index 127bc9b7..1e1476b3 100644
--- a/apps/templates/sub_box_tags.html
+++ b/pythonz/apps/templates/sub/box_tags.html
@@ -1,6 +1,6 @@
{% load sitecats %}
{% if item.has_categories %}
-
+
Категории
{% sitecats_categories from item %}
diff --git a/pythonz/apps/templates/sub/column_controls.html b/pythonz/apps/templates/sub/column_controls.html
new file mode 100644
index 00000000..b941c3b3
--- /dev/null
+++ b/pythonz/apps/templates/sub/column_controls.html
@@ -0,0 +1,12 @@
+{% block details_tags %}{% include "sub/box_tags.html" %}{% endblock %}
+
+{% block ad_rightbar %}
+
+ {% include "sub/box_ads.html" with area="rightbar" type="details" %}
+
+
+{% endblock %}
+
+{% block right_bar %}
+ {% include "sub/hint.html" %}
+{% endblock %}
diff --git a/pythonz/apps/templates/sub/comments.html b/pythonz/apps/templates/sub/comments.html
new file mode 100644
index 00000000..45327142
--- /dev/null
+++ b/pythonz/apps/templates/sub/comments.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+ {% if not disable_vk %}
+
+ {% endif %}
+
+
diff --git a/pythonz/apps/templates/sub/hint.html b/pythonz/apps/templates/sub/hint.html
new file mode 100644
index 00000000..1c1c4a78
--- /dev/null
+++ b/pythonz/apps/templates/sub/hint.html
@@ -0,0 +1,15 @@
+{% load siteblocks %}
+
+{% if not hide_hint %}
+ {% siteblock "hints" as hint %}
+{% endif %}
+
+{% if hint %}
+
+{% endif %}
+
diff --git a/pythonz/apps/templates/sub/item_cover.html b/pythonz/apps/templates/sub/item_cover.html
new file mode 100644
index 00000000..5b002e9f
--- /dev/null
+++ b/pythonz/apps/templates/sub/item_cover.html
@@ -0,0 +1,11 @@
+{% load sitetree model_meta thumbs %}
+
+{% if not thumb_url %}
+ {% thumbs_get_thumb_url item.cover 100 131 item.realm as thumb_url %}
+{% endif %}
+
+{% if thumb_url %}
+
+{% else %}
+
+{% endif %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/sub/item_info.html b/pythonz/apps/templates/sub/item_info.html
new file mode 100644
index 00000000..8e37dc37
--- /dev/null
+++ b/pythonz/apps/templates/sub/item_info.html
@@ -0,0 +1,14 @@
+{% if show_date %}{{ item.time_created|date:"j E Y" }} / {% endif %}
{{ item.year|default:'' }}
+{% if show_user %}
+
+ {% if item.author %}
+ {{ item.author }}
+ {% else %}
+ {{ item.submitter }}
+ {% endif %}
+
+{% endif %}
+
+{% if item.supporters_num > 0 %}
+
{{ item.supporters_num }}
+{% endif %}
diff --git a/pythonz/apps/templates/sub/linked.html b/pythonz/apps/templates/sub/linked.html
new file mode 100644
index 00000000..c1a00e3b
--- /dev/null
+++ b/pythonz/apps/templates/sub/linked.html
@@ -0,0 +1,15 @@
+{% with linked=item.get_linked %}
+ {% if linked %}
+
+
+
+
Связанные материалы:
+
+
+
+ {% endif %}
+{% endwith %}
diff --git a/pythonz/apps/templates/sub/paginator.html b/pythonz/apps/templates/sub/paginator.html
new file mode 100644
index 00000000..92b2ebc7
--- /dev/null
+++ b/pythonz/apps/templates/sub/paginator.html
@@ -0,0 +1,23 @@
+{% if paginator.paginator.num_pages > 1 %}
+
+{% endif %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/sub/persons_links.html b/pythonz/apps/templates/sub/persons_links.html
new file mode 100644
index 00000000..d222b965
--- /dev/null
+++ b/pythonz/apps/templates/sub/persons_links.html
@@ -0,0 +1,3 @@
+{% for person in persons %}
+
{{ person.title }}
+{% endfor %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/sub/plotly.html b/pythonz/apps/templates/sub/plotly.html
new file mode 100644
index 00000000..b29069e0
--- /dev/null
+++ b/pythonz/apps/templates/sub/plotly.html
@@ -0,0 +1,2 @@
+
+
diff --git a/pythonz/apps/templates/sub/plotly_config.html b/pythonz/apps/templates/sub/plotly_config.html
new file mode 100644
index 00000000..64b6a811
--- /dev/null
+++ b/pythonz/apps/templates/sub/plotly_config.html
@@ -0,0 +1,5 @@
+locale: 'ru',
+displayModeBar: true,
+displaylogo: false,
+modeBarButtonsToRemove: ['zoom2d', 'select2d', 'lasso2d', 'toggleSpikelines', 'resetViews'],
+responsive: true
diff --git a/pythonz/apps/templates/sub/realm_card.html b/pythonz/apps/templates/sub/realm_card.html
new file mode 100644
index 00000000..4724103a
--- /dev/null
+++ b/pythonz/apps/templates/sub/realm_card.html
@@ -0,0 +1,19 @@
+
+
+
+ {% include "sub/realm_card_title.html" %}
+
+
+ {% for item in realm_data.items %}
+
+ {% if item.is_external %}
+ {{ item.title }}
+ {% else %}
+ {{ item.title }}
+ {% endif %}
+ {% if not item.src_alias and item.title != item.description %}{{ item.description }}
{% endif %}
+
+ {% endfor %}
+
+
+
diff --git a/pythonz/apps/templates/sub/realm_card_main.html b/pythonz/apps/templates/sub/realm_card_main.html
new file mode 100644
index 00000000..8f7d20b3
--- /dev/null
+++ b/pythonz/apps/templates/sub/realm_card_main.html
@@ -0,0 +1,36 @@
+{% load thumbs %}
+
+
+
+ {% include "sub/realm_card_title.html" %}
+
+
+
+ {% with item=realm_data.featured %}
+ {% block cover %}
+
+ {% thumbs_get_thumb_url item.cover 180 236 item.realm as thumb_url %}
+ {% if thumb_url %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% endblock %}
+
+ {% endwith %}
+
+
+
+
+
diff --git a/pythonz/apps/templates/sub/realm_card_title.html b/pythonz/apps/templates/sub/realm_card_title.html
new file mode 100644
index 00000000..ab4c8582
--- /dev/null
+++ b/pythonz/apps/templates/sub/realm_card_title.html
@@ -0,0 +1,8 @@
+{% load model_meta %}
+
+{% model_meta_verbose_name_plural realm_data.cls.model as realm_title%}
+
+
diff --git a/pythonz/apps/templates/sub/realms_links_tabs.html b/pythonz/apps/templates/sub/realms_links_tabs.html
new file mode 100644
index 00000000..4afee0c2
--- /dev/null
+++ b/pythonz/apps/templates/sub/realms_links_tabs.html
@@ -0,0 +1,18 @@
+
+{% for realm_name, data_tuple in realms_links.items %}
+
+ {{ realm_name }}
+
+{% endfor %}
+
+
+{% for realm_name, data_tuple in realms_links.items %}
+
+{% endfor %}
+
diff --git a/pythonz/apps/templates/sub/rst_hints.html b/pythonz/apps/templates/sub/rst_hints.html
new file mode 100644
index 00000000..073278bd
--- /dev/null
+++ b/pythonz/apps/templates/sub/rst_hints.html
@@ -0,0 +1,194 @@
+
+
+
+
+
+
+
+
+
+
+
+ Строки
+ Перевод строки трактуется как начало нового параграфа.
+
+
+
+ Ссылки
+
+ Ссылки на внешние ресурсы, начинающиеся с http форматируются автоматически.
+ Можно скрыть ссылку под именем, используя следующий код.
+ Вставка ссылки `под именем<https://pythonz.net/>`_.
+
+
+
+
+ Начертание
+
+ Для выделения слова или фразы полужирным используйте обрамление в двойные звёзды.
+ Выделение **полужирным**.
+
+ Для выделения слова или фразы курсивом используйте обрамление в звёзды.
+ Выделение *курсивом*.
+
+
+
+
+ Акцентирование
+
+ Слово или фразу можно акцентировать путём обрамления в двойные
+ апострофы.
+ Выделение ``акцентом``.
+
+
+
+
+ Подзаголовок
+
+ Подзаголовок можно создать при помощи директивы .. title::.
+ .. title:: Подзаголовок
+
+
+
+
+ Блок «На заметку»
+
+ Блок можно выделить визуально, если начать строку с помощью
+ директивы .. note::.
+ .. note:: Текст заметки.
+
+
+
+
+ Блок «Внимание»
+
+ Блок можно выделить визуально, если начать строку с помощью
+ директивы .. warning::.
+ .. warning:: Текст предупреждения.
+
+
+
+
+ Таблицы
+
+ С помощью директивы .. table:: можно вставлять таблицы. Каждая строка при этом
+ станет рядом. Строка, начинающаяся с восклицательного знака станет заголовком, а ячейки
+ разделяются вертикальной чертой |.
+
+.. table::
+
+! заголовок | заголовок 2 | заголовок 3
+1 | первый ряд | текст 1
+2 | второй ряд | текст 2
+
+
+
+ Обратите внимание на необходимость наличия двойного переноса строки после блока таблицы.
+
+
+
+
+ Цитаты
+
+
+ Для оформления цитаты, обрамите её в тройные апострофы.
+
+ ```
+Это цитата.
+```
+
+
+
+
+ Исходный код
+
+ Подсветка синтаксиса реализуется путём выделения кода в отдельный параграф,
+ начинающийся с директивы .. code:: имя_языка.
+
+ Некий текст.
+
+.. code:: python
+
+def my_function():
+ "just a test"
+ print 8/2
+
+
+И снова текст.
+ Обратите внимание на необходимость наличия двойного переноса строки после блока кода.
+ Упоминание языка не обязательно. Если он не указан, используется подсветка Python.
+
+
+
+
+ Gist от GitHub
+
+ Гисты (куски исходного кода) могут быть вставлены в текст при помощи директивы
+ .. gist:: гитхаб_логин/ид_гиста.
+ .. gist:: idlesign/c1255817bb0234d9971a
+ Обратите внимание на необходимость наличия переноса строки после блока кода.
+ Гисты можно создавать по адресу
+ https://gist.github.com/
+
+
+
+
+ Опрос от Яндекс.Форм
+
+ Виджет опроса вставляется при помощи директивы
+ .. poll:: ид_опроса.
+ .. poll:: 5cb46b0c19621d0212e32587
+ Обратите внимание на необходимость наличия переноса строки после блока кода.
+
+
+
+
+
+ Видео YouTube
+
+ Встроить проигрыватель можно при помощи директивы
+ .. video:: url_страницы_видео.
+ .. video:: https://youtu.be/ZE7WsnmGZ3U
+ Обратите внимание на необходимость наличия переноса строки после блока кода.
+
+
+
+
+ Подкаст с podster.fm
+
+ Встроить проигрыватель можно при помощи директивы
+ .. podster:: url_страницы_подкаста.
+ .. podster:: http://mtpod.podster.fm/5
+ Обратите внимание на необходимость наличия переноса строки после блока кода.
+
+
+
+
+
+
+
+
+
diff --git a/pythonz/apps/templates/sub/search_bar.html b/pythonz/apps/templates/sub/search_bar.html
new file mode 100644
index 00000000..ede78517
--- /dev/null
+++ b/pythonz/apps/templates/sub/search_bar.html
@@ -0,0 +1,9 @@
+{% load etc_misc %}
+
+
\ No newline at end of file
diff --git a/pythonz/apps/templates/sub/static_base.html b/pythonz/apps/templates/sub/static_base.html
new file mode 100644
index 00000000..6254dc78
--- /dev/null
+++ b/pythonz/apps/templates/sub/static_base.html
@@ -0,0 +1,15 @@
+{% load static %}
+{% get_static_prefix as STATIC_URL %}
+
+
+
+
+
+
+
+
+
+
diff --git a/pythonz/apps/templates/sub/static_footer.html b/pythonz/apps/templates/sub/static_footer.html
new file mode 100644
index 00000000..4bb62e9b
--- /dev/null
+++ b/pythonz/apps/templates/sub/static_footer.html
@@ -0,0 +1,15 @@
+{% load sitemetrics static %}
+{% get_static_prefix as STATIC_URL %}
+
+
+
+
+
+{% include "siteajax/cdn.html" %}
+
+
+
+{% sitemetrics by yandex for "21211468" %}
diff --git a/pythonz/apps/templates/sub/title_block.html b/pythonz/apps/templates/sub/title_block.html
new file mode 100644
index 00000000..30d266b5
--- /dev/null
+++ b/pythonz/apps/templates/sub/title_block.html
@@ -0,0 +1,24 @@
+{% load sitetree model_meta %}
+
+
+{% if not hide_breadcurmbs %}
+ {% sitetree_breadcrumbs from "main" template "sitetree/crumbs.html" %}
+{% endif %}
+
+
+{% if view.name == 'listing' %}
+ {% if realm.syndication_enabled %}
+
+
+
+ {% endif %}
+{% endif %}
+
+
+{% sitetree_page_title from "main" as title %}
+
diff --git a/pythonz/apps/templates/sub/vk_comments.html b/pythonz/apps/templates/sub/vk_comments.html
new file mode 100644
index 00000000..a3c8b682
--- /dev/null
+++ b/pythonz/apps/templates/sub/vk_comments.html
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/pythonz/apps/templates/sub/vk_head.html b/pythonz/apps/templates/sub/vk_head.html
new file mode 100644
index 00000000..99cb379f
--- /dev/null
+++ b/pythonz/apps/templates/sub/vk_head.html
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/pythonz/apps/templates/sub/ya_forgetmenot.html b/pythonz/apps/templates/sub/ya_forgetmenot.html
new file mode 100644
index 00000000..f29118ee
--- /dev/null
+++ b/pythonz/apps/templates/sub/ya_forgetmenot.html
@@ -0,0 +1,8 @@
+
+
+
diff --git a/pythonz/apps/templates/sub/ya_map.html b/pythonz/apps/templates/sub/ya_map.html
new file mode 100644
index 00000000..e0757ea2
--- /dev/null
+++ b/pythonz/apps/templates/sub/ya_map.html
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/pythonz/apps/templates/sub/ya_map_head.html b/pythonz/apps/templates/sub/ya_map_head.html
new file mode 100644
index 00000000..75603284
--- /dev/null
+++ b/pythonz/apps/templates/sub/ya_map_head.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/templates/sub_zen.html b/pythonz/apps/templates/sub/zen.html
similarity index 58%
rename from apps/templates/sub_zen.html
rename to pythonz/apps/templates/sub/zen.html
index 33e82942..1d8b3b6d 100644
--- a/apps/templates/sub_zen.html
+++ b/pythonz/apps/templates/sub/zen.html
@@ -1,8 +1,8 @@
{% load siteblocks %}
-
+
{% siteblock "zen" as zen %}
-
{{ zen.1|safe }}
{{ zen.0|safe }}
-
Тим Питерс. «Дзен Питона» import this
+
{{ zen.1|safe }}
+
Тим Питерс. «Дзен Питона» import this
diff --git a/pythonz/apps/templates/view_add.html b/pythonz/apps/templates/view_add.html
new file mode 100644
index 00000000..13d20d73
--- /dev/null
+++ b/pythonz/apps/templates/view_add.html
@@ -0,0 +1,2 @@
+{% load etc_misc %}
+{% include_ "realms/{{ realm.name_plural }}/edit.html" fallback "base_edit.html" %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/view_details.html b/pythonz/apps/templates/view_details.html
new file mode 100644
index 00000000..03b3d9ff
--- /dev/null
+++ b/pythonz/apps/templates/view_details.html
@@ -0,0 +1,2 @@
+{% load etc_misc %}
+{% include_ "realms/{{ realm.name_plural }}/details.html" fallback "base_details.html" %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/view_edit.html b/pythonz/apps/templates/view_edit.html
new file mode 100644
index 00000000..13d20d73
--- /dev/null
+++ b/pythonz/apps/templates/view_edit.html
@@ -0,0 +1,2 @@
+{% load etc_misc %}
+{% include_ "realms/{{ realm.name_plural }}/edit.html" fallback "base_edit.html" %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/view_listing.html b/pythonz/apps/templates/view_listing.html
new file mode 100644
index 00000000..4b7b1c47
--- /dev/null
+++ b/pythonz/apps/templates/view_listing.html
@@ -0,0 +1,2 @@
+{% load etc_misc %}
+{% include_ "realms/{{ realm.name_plural }}/listing.html" fallback "base_listing.html" %}
\ No newline at end of file
diff --git a/pythonz/apps/templates/view_tags.html b/pythonz/apps/templates/view_tags.html
new file mode 100644
index 00000000..396efc1a
--- /dev/null
+++ b/pythonz/apps/templates/view_tags.html
@@ -0,0 +1,2 @@
+{% load etc_misc %}
+{% include_ "realms/{{ realm.name_plural }}/tags.html" fallback "base_listing.html" %}
\ No newline at end of file
diff --git a/data/media/empty b/pythonz/apps/templatetags/__init__.py
similarity index 100%
rename from data/media/empty
rename to pythonz/apps/templatetags/__init__.py
diff --git a/pythonz/apps/templatetags/goodreads.py b/pythonz/apps/templatetags/goodreads.py
new file mode 100644
index 00000000..8f795057
--- /dev/null
+++ b/pythonz/apps/templatetags/goodreads.py
@@ -0,0 +1,18 @@
+from urllib.parse import urlencode
+
+from django import template
+from django.template.defaultfilters import safe
+
+register = template.Library()
+
+
+@register.simple_tag
+def goodreads_get_search_tag(query: str):
+ """Возврщает тег ссылки на поиск ISBN по сайту Goodreads.
+
+ :param query:
+
+ """
+ url = f"https://www.goodreads.com/search/?{urlencode({'query': query})}"
+
+ return safe(f'
{query} ')
diff --git a/pythonz/apps/templatetags/text.py b/pythonz/apps/templatetags/text.py
new file mode 100644
index 00000000..c435929e
--- /dev/null
+++ b/pythonz/apps/templatetags/text.py
@@ -0,0 +1,19 @@
+import re
+
+from django import template
+from django.template.defaultfilters import safe
+
+register = template.Library()
+
+
+RE_HREF = re.compile(r'href="([^"]+)"')
+
+
+@register.filter
+def nolinks(value: str):
+ """Убирает из html гиперссылки.
+
+ :param value:
+
+ """
+ return safe(f"{RE_HREF.sub('', value)}")
diff --git a/pythonz/apps/templatetags/thumbs.py b/pythonz/apps/templatetags/thumbs.py
new file mode 100644
index 00000000..f0b86f49
--- /dev/null
+++ b/pythonz/apps/templatetags/thumbs.py
@@ -0,0 +1,34 @@
+
+from django import template
+from django.db.models.fields.files import ImageFieldFile
+
+from ..generics.realms import RealmBase
+from ..integration.utils import get_thumb_url
+
+register = template.Library()
+
+
+@register.simple_tag(takes_context=True)
+def thumbs_get_thumb_url(
+ context,
+ image: str | ImageFieldFile,
+ width: int,
+ height: int,
+ realm: RealmBase
+) -> str:
+ """Создаёт налету уменьшенную копию указанного изображения.
+
+ :param context:
+ :param image:
+ :param width:
+ :param height:
+ :param realm:
+
+ """
+ if isinstance(image, str):
+ url = image
+
+ else:
+ url = get_thumb_url(realm, image, width, height)
+
+ return url
diff --git a/pythonz/apps/utils.py b/pythonz/apps/utils.py
new file mode 100644
index 00000000..e57af58e
--- /dev/null
+++ b/pythonz/apps/utils.py
@@ -0,0 +1,601 @@
+import logging
+import re
+from collections.abc import Callable
+from datetime import datetime, timedelta
+from textwrap import wrap
+from typing import Any
+from urllib.parse import parse_qs, urlencode, urlparse, urlsplit, urlunparse, urlunsplit
+
+from bleach import clean
+from django.contrib import messages
+from django.db import models
+from django.db.models import Model
+from django.http import HttpRequest
+from django.utils import timezone
+from django.utils.text import Truncator
+
+from .exceptions import RemoteSourceError
+from .integration.videos import VideoBroker
+
+
+def get_logger(name: str) -> logging.Logger:
+ """Возвращает объект-журналёр для использования в модулях.
+
+ :param name:
+
+ """
+ return logging.getLogger(f'pythonz.{name}')
+
+
+LOG = get_logger(__name__)
+
+
+def get_datetime_from_till(days_gap: int) -> tuple[datetime, datetime]:
+ """Возвращает даты "с" и "по", где "по" - текущая дата,
+ а "с" отстоит от неё в прошлое на указанное количество дней.
+
+ :param int days_gap:
+
+ """
+ date_till = timezone.now()
+ date_from = date_till - timedelta(days=days_gap)
+
+ return date_from, date_till
+
+
+class PersonName:
+ """Предоставляет инструменты для представления имени персоны в разном виде."""
+
+ __slots__ = ['_name', 'is_valid']
+
+ def __init__(self, name: str):
+ name = re.sub(r'\s+', ' ', name).strip()
+
+ self._name: list[str] = name.split(' ')
+
+ self.is_valid: bool = len(self._name) > 1
+ """Флаг, указывающие на то, что имя состоит хотя бы из двух частей (имя и фамилия)."""
+
+ if not self.is_valid:
+ self._name = ['', '']
+
+ @property
+ def get_variants(self) -> list[str]:
+ """Возвращает наиболее часто встречающиеся варианты представления имени."""
+ variants = []
+ other = [self.full, self.short, self.first_last, self.last_first]
+
+ for variant in other:
+ if variant and variant not in variants:
+ variants.append(variant)
+
+ return variants
+
+ @property
+ def last_first(self) -> str:
+ """Фамилия и имя (отчество/второе имя исключаются)."""
+ return f'{self._name[-1]} {self._name[0]}'.strip()
+
+ @property
+ def first_last(self) -> str:
+ """Имя и фамилия (отчество/второе имя исключаются)."""
+ return f'{self._name[0]} {self._name[-1]}'.strip()
+
+ @property
+ def first(self) -> str:
+ """Имя."""
+ return self._name[0].strip()
+
+ @property
+ def last(self) -> str:
+ """Фамилия."""
+ return self._name[-1].strip()
+
+ @property
+ def full(self) -> str:
+ """Имя, отчество, фамилия."""
+ return ' '.join(self._name).strip()
+
+ @property
+ def short(self) -> str:
+ """Возвращает инициал имени и фамилию."""
+
+ if not self.is_valid:
+ return ''
+
+ name = self._name
+
+ return f"{name[0][0]}. {' '.join(name[1:])}"
+
+
+def truncate_chars(text: str, to: int, *, html: bool = False) -> str:
+ """Укорачивает поданный на вход текст до опционально указанного количества символов."""
+ return Truncator(text).chars(to, html=html)
+
+
+def truncate_words(text: str, to: int, *, html: bool = False) -> str:
+ """Укорачивает поданный на вход текст до опционально указанного количества слов."""
+ return Truncator(text).words(to, html=html)
+
+
+def format_currency(val: int) -> str:
+ """Форматирует значение валюты, разбивая его кратно
+ тысяче для облегчения восприятия.
+
+ :param val:
+
+ """
+ return ' '.join(wrap(str(int(val))[::-1], 3))[::-1]
+
+
+def sync_many_to_many(
+ src_obj: Any,
+ model: Model,
+ m2m_attr: str,
+ related_attr: str,
+ known_items: dict[str, Model | list[Model]],
+ unknown_handler: Callable = None
+) -> list[str]:
+ """Синхронизирует (при необходимости) список из указанного атрибута
+ объекта-источника в поле многие-ко-многим указанной модели.
+
+ Возвращает список неизвестных (отсутствующих в known_items) элементов из src_obj.m2m_attr,
+ либо созданных при помощи unknown_handler.
+
+ Внимание: для правильной работы необходимо, чтобы в БД уже был и объект model и объекты из known_items.
+
+ :param src_obj: Объект-источник, в котором есть src_obj.m2m_attr, содержащий
+ список (например строк), которым будут сопоставлены объекты из known_items.
+ Либо может быть указан список напрямую.
+
+ :param model: Модель, поле которой требуется обновить при необходимости.
+
+ :param m2m_attr: Имя атрибута модели, являющегося полем многие-ко-многим.
+
+ :param related_attr: Имя атрибута, в объектах многие-ко-многим, считающееся ключевым.
+ Значения из этого атрибута ожидаются в списке из src_obj.m2m_attr.
+
+ :param known_items: Ключи - это значения из src_obj.m2m_attr,
+ а значения - это модель из отношения многие-ко-многим, либо список моделей.
+
+ :param unknown_handler: Функция-обработчик для неизвестных элементов,
+ создающая объект налету. Должна принимать элемент списка src_obj.m2m_attr,
+ по которому будет создан объект, а также словарь known_items, который следует
+ дополнить созданным объектом.
+
+ """
+ if isinstance(src_obj, list):
+ new_list = src_obj
+
+ else:
+ new_list = getattr(src_obj, m2m_attr)
+
+ if not new_list:
+ return []
+
+ m2m_model_attr = getattr(model, m2m_attr)
+ old_many = {m2m_model_attr.values_list(related_attr, flat=True)}
+
+ unknown = []
+ unknown_handler = unknown_handler or (lambda item, known_items: None)
+
+ if old_many != set(new_list):
+ # Данные двух наборов (хранящегося в БД и полученнго) не совпадают.
+ # Синхронизируем данные в БД.
+ m2m_model_attr.clear()
+ to_add = []
+
+ for item in new_list:
+
+ if isinstance(item, str):
+ item = item.strip()
+
+ if not item:
+ continue
+
+ val = known_items.get(item, None) # Модель или список моделей.
+
+ if val is None:
+
+ LOG.debug('Handling unknown item in sync_many_to_many(): %s', item)
+ val = unknown_handler(item, known_items)
+
+ if val is None:
+ unknown.append(item)
+ continue
+
+ if not isinstance(val, list):
+ val = [val]
+
+ for item in val:
+ to_add.append(item) # noqa: PERF402
+
+ m2m_model_attr.add(*to_add)
+
+ return unknown
+
+
+def update_url_qs(url: str, new_qs_params: dict) -> str:
+ """Дополняет указанный URL указанными параметрами запроса,
+ при этом заменяя значения уже имеющихся одноимённых параметров, если
+ таковые были в URL изначально
+
+ :param url:
+ :param new_qs_params:
+
+ """
+ parsed = list(urlparse(url))
+
+ parsed_qs = parse_qs(parsed[4])
+ parsed_qs.update(new_qs_params)
+ parsed[4] = urlencode(parsed_qs, doseq=True)
+
+ return urlunparse(parsed)
+
+
+class UTM:
+ """Утилиты для работы с UTM (Urchin Tracking Module) метками."""
+
+ @classmethod
+ def add_to_url(cls, url: str, source: str, medium: str, campaign: str) -> str:
+ """Добавляет UTM метки в указаный URL.
+
+ :param url:
+
+ :param source: Название источника перехода.
+ Например, pythonz, google.
+
+ :param medium: Рекламный канал.
+ Например referral, cpc, banner, email
+
+ :param campaign: Ключевое слово (название компании).
+ Например слоган продукта, промокод.
+
+ """
+ params = {
+ 'utm_source': source,
+ 'utm_medium': medium,
+ 'utm_campaign': campaign,
+ }
+
+ return update_url_qs(url, params)
+
+ @classmethod
+ def add_to_external_url(cls, url: str) -> str:
+ """Добавляет UTM метки в указанный внешний URL.
+
+ :param url:
+
+ """
+ return cls.add_to_url(url, 'pythonz', 'referral', 'item')
+
+ @classmethod
+ def add_to_internal_url(cls, url: str, source: str) -> str:
+ """Добавляет UTM метки в указанный внутренний URL.
+
+ :param url:
+ :param source:
+
+ """
+ return cls.add_to_url(url, source, 'link', 'promo')
+
+
+class BasicTypograph:
+ """Содержит базовые правила типографики.
+ Позволяет применить эти правила к строке.
+
+ """
+ rules = {
+ 'QUOTES_REPLACE': (re.compile(r'(„|“|”|(\'\'))'), '"'),
+ 'DASH_REPLACE': (re.compile(r'(-||–|—|―|−|--)'), '-'),
+
+ 'SEQUENTIAL_SPACES': (re.compile(r'([ \t]+)'), ' '),
+
+ 'DASH_EM': (re.compile(r'([ ,])-[ ]'), '\\g<1>— '),
+ 'DASH_EN': (re.compile(r'(\d+)[ ]*-[ ]*(\d+)'), '\\g<1>–\\g<2>'),
+
+ 'HELLIP': (re.compile(r'\.{2,3}'), '…'),
+ 'COPYRIGHT': (re.compile(r'\((c|с)\)'), '©'),
+ 'TRADEMARK': (re.compile(r'\(tm\)'), '™'),
+ 'TRADEMARK_R': (re.compile(r'\(r\)'), '®'),
+
+ 'QUOTES_CYR_CLOSE': (re.compile(r'(\S+)"', re.UNICODE), '\\g<1>»'),
+ 'QUOTES_CYR_OPEN': (re.compile(r'"(\S+)', re.UNICODE), '«\\g<1>'),
+ }
+
+ @classmethod
+ def apply_to(cls, input_str: str) -> str:
+
+ input_str = f' {input_str.strip()} '
+
+ for regexp, replacement in cls.rules.values():
+ input_str = re.sub(regexp, replacement, input_str)
+
+ return input_str.strip()
+
+
+def get_simple_directive(name: str) -> str:
+ """Возвращает регулярку простой однострочной директивы для TextCompiler.
+
+ :param name: Имя директивы.
+
+ """
+ return fr'\.{{2}}\s*{name}::\s*([^\n]+)[/]*\n'
+
+
+class TextCompiler:
+ """Предоставляет инструменты для RST-подобного форматирования в HTML."""
+
+ RE_CODE = re.compile(r'\.{2}\s*code::([^\n]+)?\n{1,2}(.+?)\n{3}((?=\S)|$)', re.DOTALL)
+
+ RE_TABLE = re.compile(r'\.{2}\s*table::([^\n]+)?\n{1,2}(.+?)\n{3}((?=\S)|$)', re.DOTALL)
+
+ RE_NOTE = re.compile(get_simple_directive('note'), re.DOTALL)
+
+ RE_TITLE = re.compile(get_simple_directive('title'), re.DOTALL)
+
+ RE_WARNING = re.compile(get_simple_directive('warning'), re.DOTALL)
+
+ RE_GIST = re.compile(get_simple_directive('gist'), re.DOTALL)
+
+ RE_POLL = re.compile(get_simple_directive('poll'), re.DOTALL)
+
+ RE_VIDEO = re.compile(get_simple_directive('video'), re.DOTALL)
+
+ RE_PODSTER = re.compile(get_simple_directive('podster'), re.DOTALL)
+
+ RE_IMAGE = re.compile(get_simple_directive('image'), re.DOTALL)
+
+ RE_ACCENT = re.compile(r'``([^`\n]+)``')
+
+ RE_QUOTE = re.compile(r'```\n+([^`]+)\n+```')
+
+ RE_BOLD = re.compile(r'\*{2}(\S[^*\n]+(\S)?)\*{2}')
+
+ RE_ITALIC = re.compile(r'\*(\S[^*{1,2}\n]+\S)\*')
+
+ RE_URL = re.compile(r'(? str:
+ """Преобразует rst-подобное форматичрование в html.
+
+ :param text:
+
+ """
+ def replace_href(match):
+ return f'
{url_mangle(match.group(1))} '
+
+ def replace_code(match):
+ lang = (match.group(1) or 'python').strip()
+ code = match.group(2)
+ return f'
{code}\n'
+
+ def replace_video(match):
+
+ try:
+ code, _ = VideoBroker.get_code_and_cover(match.group(1), wrap_responsive=True)
+
+ except RemoteSourceError:
+ code = '
Ошибка встраивания видео: неподдерживаемый сервис. '
+
+ return code
+
+ def replace_table(match):
+ body = match.group(2)
+ rows = []
+
+ bg_map = {
+ 'i': 'info',
+ 's': 'success',
+ 'w': 'warning',
+ 'd': 'danger',
+ }
+
+ for line in body.splitlines():
+
+ if line.startswith('! '):
+ # Заголовок таблицы.
+ rows.append(
+ f"
{' '.join(line.lstrip(' !').split(' | '))} ")
+ else:
+ attrs_row = ''
+ cells = []
+
+ for value in map(str.strip, line.split(' | ')):
+
+ attrs_cell = ''
+
+ # Подсветка. Например, !b:d+ для всего ряда или !b:d ячейки.
+ if value.startswith('!b:'):
+ bg_letter, row_sign = value[3:5]
+ value = value[5:].strip()
+
+ if bg_class := bg_map.get(bg_letter, ''):
+ attr = f' class="{bg_class}"'
+
+ if row_sign == '+':
+ attrs_row = attr
+ else:
+ attrs_cell = attr
+
+ cells.append(f'
{value} ')
+
+ rows.append(f"
{''.join(cells)} ")
+
+ rows = ''.join(rows)
+
+ return (
+ '
\n')
+
+ # Заменяем некоторые символы для правила RE_URL_WITH_TITLE, чтобы их не устранил bleach.
+ text = text.replace('
`', '▶`')
+
+ text = text.replace('<', '<')
+ text = text.replace('>', '>')
+
+ text = clean(text)
+
+ text = text.replace('\r\n', '\n')
+
+ text = re.sub(cls.RE_UL, '\\g<1> ', text)
+ text = text.replace('\n', '\n \n')
+
+ text = re.sub(cls.RE_BOLD, '\\g<1> ', text)
+ text = re.sub(cls.RE_ITALIC, '\\g<1> ', text)
+ text = re.sub(cls.RE_QUOTE, '\\g<1> ', text)
+ text = re.sub(cls.RE_ACCENT, '\\g<1>', text)
+ text = re.sub(cls.RE_CODE, replace_code, text)
+ text = re.sub(cls.RE_URL_WITH_TITLE, '\\g<1> ', text)
+ text = re.sub(cls.RE_GIST, '', text)
+
+ text = re.sub(
+ cls.RE_POLL,
+ '',
+ text)
+
+ text = re.sub(cls.RE_VIDEO, replace_video, text)
+
+ text = re.sub(cls.RE_TABLE, replace_table, text)
+
+ text = re.sub(cls.RE_TITLE, '\\g<1> ', text)
+
+ text = re.sub(
+ cls.RE_NOTE, '', text)
+ text = re.sub(
+ cls.RE_WARNING, '', text)
+
+ text = re.sub(
+ cls.RE_PODSTER,
+ '',
+ text
+ )
+ text = re.sub(
+ cls.RE_IMAGE, ' ', text)
+ text = re.sub(cls.RE_URL, replace_href, text)
+
+ text = text.replace('\n', ' ')
+
+ return text
+
+
+def url_mangle(url: str) -> str:
+ """Усекает длинные URL практически до неузноваемости, делая нефункциональным, но коротким.
+ Всё ради уменьшения длины строки.
+
+ :param url:
+
+ """
+ if len(url) <= 45:
+ return url
+
+ path, qs, frag = 2, 3, 4
+ splitted = list(urlsplit(url))
+ splitted[qs] = ''
+ splitted[frag] = ''
+
+ if splitted[path].strip('/'):
+ splitted[path] = f"<...>{splitted[path].split('/')[-1]}" # Последний кусок пути.
+
+ mangled = urlunsplit(splitted)
+
+ return mangled
+
+
+def message_info(request: HttpRequest, message: str):
+ """Регистрирует сообщение информирующего типа для вывода пользователю на странице.
+
+ :param request:
+ :param message:
+
+ """
+ messages.add_message(request, messages.INFO, message, extra_tags='info')
+
+
+def message_warning(request: HttpRequest, message: str):
+ """Регистрирует предупреждающее сообщение для вывода пользователю на странице.
+
+ :param request:
+ :param message:
+
+ """
+ messages.add_message(request, messages.WARNING, message, extra_tags='warning')
+
+
+def message_success(request: HttpRequest, message: str):
+ """Регистрирует ободряющее сообщение для вывода пользователю на странице.
+
+ :param request:
+ :param message:
+
+ """
+ messages.add_message(request, messages.SUCCESS, message, extra_tags='success')
+
+
+def message_error(request: HttpRequest, message: str):
+ """Регистрирует сообщение об ошибке для вывода пользователю на странице.
+
+ :param request:
+ :param message:
+
+ """
+ messages.add_message(request, messages.ERROR, message, extra_tags='danger')
+
+
+TRANSLATION_DICT = str.maketrans(
+ "ёЁ!\"№;%:?йцукенгшщзхъфывапролджэячсмитьбю.ЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭ/ЯЧСМИТЬБЮ,",
+ "`~!@#$%^&qwertyuiop[]asdfghjkl;'zxcvbnm,./QWERTYUIOP{}ASDFGHJKL:\"|ZXCVBNM<>?"
+)
+
+RE_NON_ASCII = re.compile('[^\x00-\x7F]')
+
+
+def swap_layout(src_text: str) -> str:
+ """Заменяет кириллические символы строки на латинские в соответствии
+ с классической раскладкой клавиатуры, если строка не содержит символов кириллицы,
+ то возвращатся пустая строка, символизируя, что трансляция не производилась.
+
+ :param src_text:
+
+ """
+ if not RE_NON_ASCII.match(src_text):
+ return ''
+
+ return src_text.translate(TRANSLATION_DICT)
+
+
+def search_models(term: str, *, search_in=list[type[models.Model]]) -> tuple[str, list[models.Model]]:
+ """Производит поиск указанной строки в указанных областях.
+ Возвращает результаты поиска.
+
+ :param term: Строка для посика.
+ :param search_in: Области иска.
+
+ """
+ search_term = term.strip(' ()')[:200]
+
+ if not search_term:
+ return search_term, []
+
+ swapped = swap_layout(search_term)
+
+ results = []
+
+ for model_cls in search_in:
+ results.extend(model_cls.find(search_term, swapped))
+
+ return search_term, results
diff --git a/pythonz/apps/uwsgiinit.py b/pythonz/apps/uwsgiinit.py
new file mode 100644
index 00000000..70e41b37
--- /dev/null
+++ b/pythonz/apps/uwsgiinit.py
@@ -0,0 +1,122 @@
+from django.utils import timezone
+from sitemessage.toolbox import check_undelivered, cleanup_sent_messages, send_scheduled_messages
+from uwsgiconf.runtime.scheduling import register_cron, register_timer
+
+from .commands import clean_missing_refs, publish_postponed
+from .models import PEP, App, Event, ExternalResource, Summary, Vacancy
+from .sitemessages import PythonzEmailDigest
+
+
+def _nsk(hour: int) -> int:
+ """Транслирует новосибирский час в час по времени страны хостинга.
+
+ :param hour:
+
+ """
+ return hour - 6
+
+
+@register_timer(60)
+def task_send_messages(sig_num):
+ """Отправка оповещений."""
+ send_scheduled_messages(priority=1)
+
+
+@register_cron(hour=-4, minute=30)
+def task_get_vacancies(sig_num):
+ """Синхронизация вакансий."""
+ Vacancy.update_statuses()
+ Vacancy.fetch_items()
+
+
+@register_cron(hour=_nsk(15), minute=25)
+def task_get_events(sig_num):
+ """Синхронизация событий."""
+ Event.fetch_items()
+
+
+@register_cron(hour=-2, minute=1)
+def task_get_resources(sig_num):
+ """Подтягивание данных из внешних ресурсов."""
+ ExternalResource.fetch_new()
+
+
+@register_cron(hour=-2, minute=15)
+def task_publish_postponed(sig_num):
+ """Публикация отложенных материалов.
+
+ Не ранее 13 Нск (9 Мск).
+ Не позднее 21 Нск (17 Мск).
+
+ """
+ if _nsk(13) <= timezone.now().hour < _nsk(21):
+ publish_postponed()
+
+
+@register_cron(weekday=0, hour=_nsk(14), minute=10)
+def task_create_summary(sig_num):
+ """Еженедельная статья-сводка.
+ Воскресенье 14:10 Нск (10:10 Мск).
+
+ """
+ Summary.create_article()
+
+
+@register_cron(weekday=5, hour=_nsk(13), minute=20)
+def task_sync_peps(sig_num):
+ """Синхронизация данных PEP.
+ Пятница 13:20 Нск (9:20 Мск).
+
+ """
+ PEP.sync_from_repository()
+
+
+@register_cron(weekday=5, hour=_nsk(7), minute=40)
+def task_digest_create(sig_num):
+ """Компиляция еженедельного дайджеста.
+ Пятница 7:40 Нск (3:40 Мск).
+
+ """
+ PythonzEmailDigest.create()
+
+
+@register_cron(weekday=5, hour=_nsk(13), minute=0)
+def task_digest_send(sig_num):
+ """Рассылка еженедельного дайджеста.
+ Пятница 13:00 Нск (9:00 Мск).
+
+ """
+ send_scheduled_messages(priority=7)
+
+
+@register_cron(weekday=0, hour=_nsk(7), minute=13)
+def task_clean_missing_refs(sig_num):
+ """Очистка от устаревших промахов справочника.
+ Воскресенье 7:13 Нск (3:13 Мск).
+
+ """
+ clean_missing_refs()
+
+
+@register_cron(weekday=0, hour=_nsk(7), minute=15)
+def task_clean_sent_msg(sig_num):
+ """Очистка от отправленных сообщений.
+ Воскресенье 7:15 Нск (3:15 Мск).
+
+ """
+ cleanup_sent_messages()
+
+
+@register_cron(hour=-14, minute=10)
+def task_notify_undelivered(sig_num):
+ """Оповещение о недоставленных сообщениях."""
+ check_undelivered()
+
+
+@register_cron(weekday=1, hour=_nsk(10), minute=5)
+def task_app_stats_update(sig_num):
+ """Обновление данных о загрузках приложений.
+ Понедельник 10:05 Нск (6:05 Мск).
+
+ """
+ App.actualize_downloads()
diff --git a/pythonz/apps/views/__init__.py b/pythonz/apps/views/__init__.py
new file mode 100644
index 00000000..49ee10f3
--- /dev/null
+++ b/pythonz/apps/views/__init__.py
@@ -0,0 +1,13 @@
+from .basic import login, page_not_found, permission_denied, server_error
+from .callback import telebot
+from .categories import CategoryListingView
+from .ide import ide
+from .index import index
+from .peps import PepListingView
+from .persons import PersonDetailsView
+from .places import PlaceDetailsView, PlaceListingView
+from .references import ReferenceDetailsView, ReferenceListingView
+from .search import search
+from .users import UserDetailsView, UserEditView, user_settings
+from .vacancies import VacancyListingView
+from .versions import VersionDetailsView
diff --git a/pythonz/apps/views/basic.py b/pythonz/apps/views/basic.py
new file mode 100644
index 00000000..f53bf85b
--- /dev/null
+++ b/pythonz/apps/views/basic.py
@@ -0,0 +1,38 @@
+from django.http import HttpResponse
+from django.shortcuts import render
+from django.views.defaults import page_not_found as dj_page_not_found
+from django.views.defaults import permission_denied as dj_permission_denied
+from django.views.defaults import server_error as dj_server_error
+from sitegate.decorators import redirect_signedin, signin_view, signup_view
+from sitegate.signup_flows.classic import SimpleClassicWithEmailSignup
+
+from ..generics.views import HttpRequest
+
+
+# Наши страницы ошибок.
+def permission_denied(request: HttpRequest, exception: Exception) -> HttpResponse:
+ return dj_permission_denied(request, exception, template_name='static/403.html')
+
+
+def page_not_found(request: HttpRequest, exception: Exception) -> HttpResponse:
+ return dj_page_not_found(request, exception, template_name='static/404.html')
+
+
+def server_error(request: HttpRequest):
+ return dj_server_error(request, template_name='static/500.html')
+
+
+@redirect_signedin
+@signin_view(
+ widget_attrs={'class': 'form-control', 'placeholder': lambda f: f.label},
+ template='form_bootstrap4'
+)
+@signup_view(
+ widget_attrs={'class': 'form-control', 'placeholder': lambda f: f.label},
+ template='form_bootstrap4',
+ flow=SimpleClassicWithEmailSignup,
+ verify_email=True
+)
+def login(request: HttpRequest) -> HttpResponse:
+ """Страница авторизации и регистрации."""
+ return render(request, 'static/login.html')
diff --git a/pythonz/apps/views/callback.py b/pythonz/apps/views/callback.py
new file mode 100644
index 00000000..1db799ee
--- /dev/null
+++ b/pythonz/apps/views/callback.py
@@ -0,0 +1,16 @@
+from django.http import HttpResponse
+from django.views.decorators.csrf import csrf_exempt
+
+from ..generics.views import HttpRequest
+from ..integration.telegram import handle_request
+
+
+@csrf_exempt
+def telebot(request: HttpRequest) -> HttpResponse:
+ """Обрабатывает запросы от Telegram.
+
+ :param request:
+
+ """
+ handle_request(request)
+ return HttpResponse()
diff --git a/pythonz/apps/views/categories.py b/pythonz/apps/views/categories.py
new file mode 100644
index 00000000..82e1a283
--- /dev/null
+++ b/pythonz/apps/views/categories.py
@@ -0,0 +1,49 @@
+from django.shortcuts import get_object_or_404
+from django.urls import reverse
+from sitecats.toolbox import get_category_aliases_under, get_category_lists
+
+from ..generics.views import HttpRequest, RealmView
+from ..models import Category
+
+
+class CategoryListingView(RealmView):
+ """Выводит список известных категорий, либо список сущностей для конкретной категории."""
+
+ def get(self, request: HttpRequest, obj_id: int = None):
+ from ..realms import get_realms # noqa: PLC0415
+
+ realms = get_realms().values()
+
+ if obj_id is None: # Запрошен список всех известных категорий.
+ item = get_category_lists(
+ init_kwargs={
+ 'show_title': True,
+ 'show_links': lambda cat: reverse(self.realm.get_details_urlname(), args=[cat.id])
+ },
+ additional_parents_aliases=get_category_aliases_under())
+
+ return self.render(request, {'item': item, 'realms': realms})
+
+ # Выводим список материалов (разбитых по областям сайта) для конкретной категории.
+ category = get_object_or_404(Category.objects.select_related('parent'), pk=obj_id)
+
+ realms_links = {}
+
+ for realm in realms:
+ realm_model = realm.model
+
+ if not hasattr(realm_model, 'categories'): # ModelWithCategory
+ continue
+
+ items = realm_model.get_objects_in_category(category)
+
+ if not items:
+ continue
+
+ realm_title = realm_model.get_verbose_name_plural()
+
+ _, plural = realm.get_names()
+
+ realms_links[realm_title] = (plural, items)
+
+ return self.render(request, {self.realm.name: category, 'item': category, 'realms_links': realms_links})
diff --git a/pythonz/apps/views/ide.py b/pythonz/apps/views/ide.py
new file mode 100644
index 00000000..9e1e1f9b
--- /dev/null
+++ b/pythonz/apps/views/ide.py
@@ -0,0 +1,27 @@
+from django.http import HttpResponse
+from django.shortcuts import render
+
+from ..generics.views import HttpRequest
+from ..models import Reference
+from ..utils import search_models
+
+
+def ide(request: HttpRequest) -> HttpResponse:
+ """Страница подсказок для IDE."""
+
+ term = request.GET.get('term', '')
+ results = []
+ error = ''
+
+ ide_version = request.headers.get('Ide-Version')
+ ide_name = request.headers.get('Ide-Name')
+
+ if ide_version and ide_name:
+
+ if ide_name in {'IntelliJ IDEA', 'PyCharm'}:
+ term, results = search_models(term, search_in=(Reference,))
+
+ else:
+ error = f'Используемая вами среда разработки "{ide_name} {ide_version}" не поддерживается.'
+
+ return render(request, 'realms/references/ide.html', {'term': term, 'results': results, 'error': error})
diff --git a/pythonz/apps/views/index.py b/pythonz/apps/views/index.py
new file mode 100644
index 00000000..e4d65c74
--- /dev/null
+++ b/pythonz/apps/views/index.py
@@ -0,0 +1,63 @@
+from datetime import timedelta
+from itertools import groupby
+from operator import attrgetter
+from typing import TYPE_CHECKING
+
+from django.http import HttpResponse
+from django.shortcuts import render
+from django.utils.timezone import now
+from django.views.decorators.cache import cache_page
+from django.views.decorators.csrf import csrf_protect
+
+from ..generics.views import HttpRequest
+from ..models import ExternalResource
+
+if TYPE_CHECKING:
+ from ..generics.models import RealmBaseModel
+
+
+@cache_page(900) # 15 минут
+@csrf_protect
+def index(request: HttpRequest) -> HttpResponse:
+ """Индексная страница."""
+ from ..realms import get_realms # noqa PLC0415
+
+ realms_data = []
+ realms_registry = get_realms()
+
+ externals = ExternalResource.objects.filter(realm_name__in=realms_registry.keys())
+ externals = {
+ k: sorted(v, key=attrgetter('id'), reverse=True)
+ for k, v in groupby(externals, attrgetter('realm_name'))}
+
+ max_items = 6
+ min_local = 2
+
+ dt_stale_featured = now() - timedelta(days=7)
+
+ for name, realm in realms_registry.items():
+
+ if not realm.show_on_main:
+ continue
+
+ realm_externals = externals.get(name, [])[:max_items]
+ count_local = max_items - len(realm_externals)
+
+ if count_local < min_local:
+ count_local = min_local
+
+ realm_model = realm.model
+ items: list[RealmBaseModel] = list(realm_model.get_actual()[:count_local])
+ items.extend(realm_externals[:max_items - count_local])
+
+ featured = None
+ if items:
+ featured = items[0]
+
+ realms_data.append({
+ 'featured': realm_model.get_featured(candidate=featured, dt_stale=dt_stale_featured),
+ 'cls': realm,
+ 'items': items,
+ })
+
+ return render(request, 'index.html', {'realms_data': realms_data})
diff --git a/pythonz/apps/views/peps.py b/pythonz/apps/views/peps.py
new file mode 100644
index 00000000..ef581cff
--- /dev/null
+++ b/pythonz/apps/views/peps.py
@@ -0,0 +1,46 @@
+from enum import Enum
+from sys import maxsize
+
+from django.db.models import QuerySet
+
+from ..generics.views import HttpRequest, ListingView
+from ..models import PEP
+
+
+class PepListingView(ListingView):
+ """Представление со списком PEP."""
+
+ def get_paginator_per_page(self, request: HttpRequest) -> int:
+ if request.disable_paginator:
+ return maxsize
+ return super().get_paginator_per_page(request)
+
+ def apply_object_filter(self, *, attrs: dict[str, type[Enum]], objects: QuerySet):
+
+ applied = False
+
+ for attr, enum in attrs.items():
+ val = self.request.GET.get(attr)
+
+ if val and val.isdigit():
+
+ val = int(val)
+
+ if val in enum.values:
+ objects = objects.filter(**{attr: val})
+ applied = True
+
+ return applied, objects
+
+ def get_paginator_objects(self) -> QuerySet:
+
+ objects = super().get_paginator_objects()
+
+ applied, objects = self.apply_object_filter(attrs={
+ 'status': PEP.Status,
+ 'type': PEP.Type,
+ }, objects=objects)
+
+ self.request.disable_paginator = applied
+
+ return objects
diff --git a/pythonz/apps/views/persons.py b/pythonz/apps/views/persons.py
new file mode 100644
index 00000000..f3d82319
--- /dev/null
+++ b/pythonz/apps/views/persons.py
@@ -0,0 +1,9 @@
+from ..generics.views import DetailsView, HttpRequest
+
+
+class PersonDetailsView(DetailsView):
+ """Представление с детальной информацией о персоне."""
+
+ def update_context(self, context: dict, request: HttpRequest):
+ user = context['item']
+ context['materials'] = lambda: user.get_materials() # Ленивость для кеша в шаблоне
diff --git a/pythonz/apps/views/places.py b/pythonz/apps/views/places.py
new file mode 100644
index 00000000..10d89cf6
--- /dev/null
+++ b/pythonz/apps/views/places.py
@@ -0,0 +1,53 @@
+from django.contrib.auth.decorators import login_required
+from django.http import HttpResponse
+from django.utils.decorators import method_decorator
+from siteajax.toolbox import ajax_dispatch
+
+from ..generics.views import DetailsView, HttpRequest, RealmView
+from ..models import Community, Event, Place, User, Vacancy
+
+
+class PlaceDetailsView(DetailsView):
+ """Представление с детальной информацией о месте."""
+
+ @method_decorator(login_required)
+ def set_im_here(self, request: HttpRequest, obj_id: int) -> HttpResponse:
+ """Обслуживает ajax-запрос. Прописывает место и часовой пояс в профиль пользователя.
+
+ :param request:
+ :param obj_id:
+
+ """
+ user = request.user
+ user.place = self.get_object(request=request, obj_id=obj_id)
+ user.set_timezone_from_place()
+ user.save()
+
+ return HttpResponse()
+
+ @ajax_dispatch({
+ 'set-im-here': set_im_here
+ })
+ def get(self, request: HttpRequest, obj_id: int) -> HttpResponse:
+ # Метод перекрыт для добавления AJAX-обработчика.
+ return super().get(request, obj_id)
+
+ def update_context(self, context: dict, request: HttpRequest):
+ place = context['item']
+
+ if request.user.is_authenticated:
+ context['allow_im_here'] = (request.user.place != place)
+
+ context['users'] = User.get_actual().filter(place=place)
+ context['communities'] = Community.get_actual().filter(place=place)
+ context['events'] = Event.get_actual().filter(place=place)
+ context['vacancies'] = Vacancy.get_actual().filter(place=place)
+ context['stats_salary'] = Vacancy.get_salary_stats(place)
+
+
+class PlaceListingView(RealmView):
+ """Представление с картой и списком всех известных мест."""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ places = Place.get_actual().order_by('-supporters_num', 'title')
+ return self.render(request, {self.realm.name_plural: places})
diff --git a/pythonz/apps/views/references.py b/pythonz/apps/views/references.py
new file mode 100644
index 00000000..53cf91bf
--- /dev/null
+++ b/pythonz/apps/views/references.py
@@ -0,0 +1,25 @@
+from django.http import HttpResponse
+from django.shortcuts import redirect
+
+from ..generics.views import DetailsView, HttpRequest, RealmView
+
+
+class ReferenceListingView(RealmView):
+ """Представление со списком справочников."""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ # Справочник один, поэтому перенаправляем сразу на него.
+ return redirect(self.realm.get_details_urlname(slugged=True), 'python', permanent=True)
+
+
+class ReferenceDetailsView(DetailsView):
+ """Представление статьи справочника."""
+
+ def update_context(self, context: dict, request: HttpRequest):
+
+ reference = context['item']
+ context['children'] = reference.get_actual(parent=reference).order_by('title')
+
+ if reference.parent is not None:
+ context['siblings'] = reference.get_actual(
+ parent=reference.parent, exclude_id=reference.id).order_by('title')
diff --git a/pythonz/apps/views/search.py b/pythonz/apps/views/search.py
new file mode 100644
index 00000000..f7d7e5f1
--- /dev/null
+++ b/pythonz/apps/views/search.py
@@ -0,0 +1,53 @@
+from urllib.parse import quote_plus
+
+from django.conf import settings
+from django.http import HttpResponse
+from django.shortcuts import redirect, render
+
+from ..generics.views import HttpRequest
+from ..models import App, Category, Person, Reference, ReferenceMissing
+from ..utils import message_warning, search_models
+
+
+def search(request: HttpRequest) -> HttpResponse:
+ """Страница с результатами поиска по справочнику.
+ Если найден один результат, перенаправляет на страницу результата.
+
+ """
+ search_term, results = search_models(
+ request.POST.get('text', ''), search_in=(
+ Category,
+ Person,
+ Reference,
+ App,
+ ))
+
+ if not search_term:
+ return redirect('index')
+
+ if not results:
+ # Поиск не дал результатов. Запомним, что искали и сообщим администраторам,
+ # чтобы приняли меры по возможности.
+
+ ReferenceMissing.add(search_term)
+
+ message_warning(
+ request, 'Поиск по справочнику и категориям не дал результатов, '
+ 'и мы переключились на поиск по всему сайту.')
+
+ # Перенаправляем на поиск по всему сайту.
+ redirect_response = redirect('search_site')
+ redirect_response['Location'] += f'?searchid={settings.YANDEX_SEARCH_ID}&text={quote_plus(search_term)}'
+
+ return redirect_response
+
+ results_len = len(results)
+
+ if results_len == 1:
+ return redirect(results[0].get_absolute_url())
+
+ return render(request, 'static/search.html', {
+ 'search_term': search_term,
+ 'results': results,
+ 'results_len': results_len,
+ })
diff --git a/pythonz/apps/views/users.py b/pythonz/apps/views/users.py
new file mode 100644
index 00000000..e8cdea2f
--- /dev/null
+++ b/pythonz/apps/views/users.py
@@ -0,0 +1,58 @@
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse
+from django.shortcuts import redirect
+from sitemessage.toolbox import get_user_preferences_for_ui, set_user_preferences_from_request
+
+from ..exceptions import RedirectRequired
+from ..generics.views import DetailsView, EditView, HttpRequest
+from ..models import User
+
+
+class UserDetailsView(DetailsView):
+ """Представление с детальной информацией о пользователе."""
+
+ def check_view_permissions(self, request: HttpRequest, item: User):
+ super().check_view_permissions(request, item)
+
+ if not item.profile_public and item != request.user:
+ # Закрываем доступ к непубличным профилям.
+ raise PermissionDenied
+
+ def update_context(self, context: dict, request: HttpRequest):
+
+ user = context['item']
+ context['bookmarks'] = user.get_bookmarks()
+ context['stats'] = lambda: user.get_stats() # Ленивость для кеша в шаблоне
+
+ if user == request.user:
+ context['drafts'] = user.get_drafts()
+
+
+class UserEditView(EditView):
+ """Представление редактирования пользователя."""
+
+ def check_edit_permissions(self, request: HttpRequest, item: User):
+ # Пользователи не могут редактировать других пользователей.
+ if item != request.user:
+ raise PermissionDenied
+
+ def update_context(self, context: dict, request: HttpRequest):
+
+ if request.POST:
+ prefs_were_set = set_user_preferences_from_request(request)
+
+ if prefs_were_set:
+ raise RedirectRequired
+
+ subscr_prefs = get_user_preferences_for_ui(request.user, new_messengers_titles={
+ 'smtp': ' '
+ })
+
+ context['subscr_prefs'] = subscr_prefs
+
+
+@login_required
+def user_settings(request: HttpRequest) -> HttpResponse:
+ """Перенаправляет на страницу настроек текущего пользователя."""
+ return redirect('users:edit', request.user.pk)
diff --git a/pythonz/apps/views/vacancies.py b/pythonz/apps/views/vacancies.py
new file mode 100644
index 00000000..991715d0
--- /dev/null
+++ b/pythonz/apps/views/vacancies.py
@@ -0,0 +1,14 @@
+
+from ..generics.views import HttpRequest, ListingView
+from ..models import Vacancy
+
+
+class VacancyListingView(ListingView):
+ """Представление со списком вакансий."""
+
+ def update_context(self, context: dict, request: HttpRequest):
+ context['stats_salary'] = Vacancy.get_salary_stats()
+ context['stats_places'] = Vacancy.get_places_stats()
+
+ def get_most_voted_objects(self) -> list:
+ return []
diff --git a/pythonz/apps/views/versions.py b/pythonz/apps/views/versions.py
new file mode 100644
index 00000000..185e3324
--- /dev/null
+++ b/pythonz/apps/views/versions.py
@@ -0,0 +1,11 @@
+from ..generics.views import DetailsView, HttpRequest
+
+
+class VersionDetailsView(DetailsView):
+ """Представление с детальной информацией о версии Питона."""
+
+ def update_context(self, context: dict, request: HttpRequest):
+ version = context['item']
+ context['added'] = version.reference_added.order_by('title')
+ context['deprecated'] = version.reference_deprecated.order_by('title')
+ context['peps'] = version.peps.order_by('num')
diff --git a/pythonz/apps/zen.py b/pythonz/apps/zen.py
new file mode 100644
index 00000000..a705dbaf
--- /dev/null
+++ b/pythonz/apps/zen.py
@@ -0,0 +1,36 @@
+from random import choice
+
+from siteblocks.siteblocksapp import register_dynamic_block
+
+ZEN = (
+ ('Beautiful is better than ugly', 'Красивое лучше безобразного'),
+ ('Explicit is better than implicit', 'Явное лучше подразумеваемого'),
+ ('Simple is better than complex', 'Простое лучше сложного'),
+ ('Complex is better than complicated', 'Сложное лучше усложнённого'),
+ ('Flat is better than nested', 'Плоское лучше вложенного'),
+ ('Sparse is better than dense', 'Разреженное лучше плотного'),
+ ('Readability counts', 'Важна читабельность'),
+ ('Special cases aren\'t special enough to break the rules.\nAlthough practicality beats purity',
+ 'Исключения недостаточно исключительны, чтобы нарушать правила.\nХотя, практичность превыше безупречности'),
+ ('Errors should never pass silently.\nUnless explicitly silenced',
+ 'Ошибки не должны оставаться незамеченными.\nЕсли не были заглушены явно'),
+ ('In the face of ambiguity, refuse the temptation to guess',
+ 'Пред лицом многозначности презрите желание догадываться'),
+ ('There should be one — and preferably only one — obvious way to do it.\n'
+ 'Although that way may not be obvious at first unless you\'re Dutch',
+ 'Должен быть один — и лучше единственный — очевидный способ достичь цели.\n'
+ 'Впрочем, если вы не голландец, поначалу этот способ может казаться не столь очевидным'),
+ ('Now is better than never.\nAlthough never is often better than right now',
+ 'Лучше сейчас, чем никогда.\nВпрочем, часто никогда лучше, чем прямо сейчас '),
+ ('If the implementation is hard to explain, it\'s a bad idea',
+ 'Если реализацию трудно описать, значит идея была никудышной'),
+ ('If the implementation is easy to explain, it may be a good idea',
+ 'Если реализацию легко описать — возможно, идея была хорошей'),
+ ('Namespaces are one honking great idea — let\'s do more of those!',
+ 'Пространства имён были блестящей идеей — генерируем ещё!'),
+)
+
+
+def register_zen_siteblock():
+ """Регистрирует динамический блок сайта, наполняемый цитатами из дзена."""
+ register_dynamic_block('zen', lambda **kwargs: choice(ZEN))
diff --git a/pythonz/manage.py b/pythonz/manage.py
new file mode 100755
index 00000000..8ec57422
--- /dev/null
+++ b/pythonz/manage.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+import os
+import sys
+
+
+def main():
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pythonz.settings')
+
+ try:
+ from django.core.management import execute_from_command_line # noqa: PLC0415
+
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/pythonz/settings/__init__.py b/pythonz/settings/__init__.py
new file mode 100644
index 00000000..ca58cbf6
--- /dev/null
+++ b/pythonz/settings/__init__.py
@@ -0,0 +1,18 @@
+"""
+Этот модуль является единой точкой входа для конфигураций.
+
+Функция import_by_environment() подгрузит сюда символы из модулей для текущей среды.
+Например, для среды разработки подгрузится settings_development.py.
+"""
+from envbox import get_environment, import_by_environment
+
+current_env = import_by_environment(
+ # For production one can place `/var/lib/pythonz/environ` file with `production` as it contents.
+ get_environment(detectors_opts={'file': {'source': '/var/lib/pythonz/environ'}}),
+ module_name_pattern='env_%s'
+)
+
+
+IN_PRODUCTION = current_env == 'production'
+
+print(f'# Environment type: {current_env}')
diff --git a/pythonz/settings/base.py b/pythonz/settings/base.py
new file mode 100644
index 00000000..ebd42167
--- /dev/null
+++ b/pythonz/settings/base.py
@@ -0,0 +1,122 @@
+from .sub_paths import * # noqa
+from .sub_email import * # noqa
+from .sub_intergration import * # noqa
+from .sub_logging import * # noqa
+from .sub_security import * # noqa
+from .sub_sentry import init_sentry # noqa
+
+from pythonz import VERSION
+
+SITE_URL = 'https://pythonz.net'
+PROJECT_SOURCE_URL = 'https://github.com/idlesign/pythonz'
+USER_AGENT = f'pythonz.net/{VERSION} (press@pythonz.net)'
+
+ROBOT_USER_ID = 1
+"""Идентификатор пользователя-робота."""
+
+SUMMARY_CATEGORY_ID = 1
+"""Идентификатор категории Сводок."""
+
+AGRESSIVE_MODE = False
+"""Переводит проект в агрессивный режим: задействует различную машинерию для привлечения внимания к проекту."""
+
+DEBUG = False
+
+ADMINS = ()
+MANAGERS = ADMINS
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': f'{PROJECT_DIR_STATE}/data.db',
+ }
+}
+
+
+SITE_ID = 1
+
+ROOT_URLCONF = 'pythonz.urls'
+WSGI_APPLICATION = 'pythonz.wsgi.application'
+
+LANGUAGE_CODE = 'ru'
+TIME_ZONE = 'Asia/Novosibirsk'
+
+USE_I18N = True
+USE_L10N = True
+USE_TZ = True
+
+LOGIN_URL = 'login'
+LOGIN_REDIRECT_URL = 'settings'
+LOGOUT_REDIRECT_URL = 'index'
+
+ADMIN_URL = 'admin'
+MEDIA_URL = '/media/'
+STATIC_URL = '/static/'
+
+
+INSTALLED_APPS = [
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.sites',
+ 'django.contrib.sitemaps',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'django.contrib.admin',
+
+ 'admirarchy',
+ 'siteajax',
+ 'sitecats',
+ 'siteflags',
+ 'sitetree',
+ 'siteblocks',
+ 'sitegate',
+ 'sitemetrics',
+ 'siteprefs',
+ 'sitemessage',
+ 'etc',
+
+ 'uwsgiconf.contrib.django.uwsgify',
+
+ 'simple_history',
+ 'robots',
+
+ 'pythonz.apps',
+
+]
+
+MIDDLEWARE = [
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+ 'django.middleware.locale.LocaleMiddleware',
+ 'pythonz.apps.middleware.TimezoneMiddleware',
+ 'simple_history.middleware.HistoryRequestMiddleware',
+]
+
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'debug': DEBUG,
+ 'context_processors': [
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+
+ROBOTS_CACHE_TIMEOUT = 6 * 60 * 60
+
+DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
diff --git a/pythonz/settings/env_development.py b/pythonz/settings/env_development.py
new file mode 100644
index 00000000..66462acc
--- /dev/null
+++ b/pythonz/settings/env_development.py
@@ -0,0 +1,42 @@
+#
+# Для конфигурирования в ходе разработки используйте этот файл, а не settings_base.py
+#
+from .base import *
+
+DEBUG = True
+SITE_URL = 'http://localhost:8000'
+
+ALLOWED_HOSTS = [
+ 'localhost',
+ '127.0.0.1',
+ '[::1]',
+]
+
+INTERNAL_IPS = [
+ '127.0.0.1',
+ '[::1]',
+]
+
+
+ADMINS = (
+ ('me', 'me@some.where'),
+)
+
+TEMPLATES[0]['OPTIONS']['debug'] = True
+
+INSTALLED_APPS.append('debug_toolbar')
+MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware')
+
+CACHES = {
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
+ }
+}
+
+LOGGERS.update({
+
+})
+
+
+CSRF_COOKIE_SECURE = False
+SESSION_COOKIE_SECURE = False
diff --git a/pythonz/settings/env_testing.py b/pythonz/settings/env_testing.py
new file mode 100644
index 00000000..37564eb0
--- /dev/null
+++ b/pythonz/settings/env_testing.py
@@ -0,0 +1,18 @@
+#
+# Этот файл конфигурации используется для тестов.
+#
+from .base import *
+
+SITEPREFS_DISABLE_AUTODISCOVER = True
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': ':memory:'
+ }
+}
+
+PARTNER_IDS = {
+ 'booksru': 'abc',
+ 'litres': 'def',
+}
diff --git a/pythonz/settings/sub_email.py b/pythonz/settings/sub_email.py
new file mode 100644
index 00000000..33d6abd9
--- /dev/null
+++ b/pythonz/settings/sub_email.py
@@ -0,0 +1,8 @@
+
+SERVER_EMAIL = 'some@email.com'
+EMAIL_HOST = 'some.host.com'
+EMAIL_HOST_USER = 'some@email.com'
+EMAIL_HOST_PASSWORD = 'not_a_secret'
+EMAIL_USE_TLS = True
+
+EMAIL_BACKEND = 'sitemessage.backends.EmailBackend'
diff --git a/pythonz/settings/sub_intergration.py b/pythonz/settings/sub_intergration.py
new file mode 100644
index 00000000..8c0ee928
--- /dev/null
+++ b/pythonz/settings/sub_intergration.py
@@ -0,0 +1,47 @@
+from .sub_email import (
+ EMAIL_HOST,
+ EMAIL_HOST_PASSWORD,
+ EMAIL_HOST_USER,
+ EMAIL_USE_TLS,
+ SERVER_EMAIL,
+)
+
+SOCKS5_PROXY = ''
+"""Адрес socks5 для запросов во вне."""
+
+PARTNER_IDS = {}
+"""Здесь указываются партнёрские идентификаторы."""
+
+GOOGLE_API_KEY = 'not_a_secret'
+YANDEX_SEARCH_ID = 'not_a_secret'
+YANDEX_GEOCODER_KEY = 'not_a_secret'
+
+TELEGRAM_BOT_TOKEN = 'not_a_secret'
+TELEGRAM_BOT_URL = 'not_a_secret'
+TELEGRAM_GROUP = 'group_id or @channel_name'
+
+VK_ACCESS_TOKEN = 'not_a_secret'
+VK_GROUP = 'group id prefixed with -'
+
+# Сюда помещаются реквизиты для пользования соответствующими службами доставки сообщений (cм. sitemessages.py).
+SITEMESSAGE_INIT_BUILTIN_MESSAGE_TYPES = False
+SITEMESSAGE_SHORTCUT_EMAIL_MESSAGE_TYPE = 'simple'
+
+SITEMESSAGES_SETTINGS = {
+ 'smtp': [
+ SERVER_EMAIL,
+ EMAIL_HOST_USER,
+ EMAIL_HOST_PASSWORD,
+ EMAIL_HOST,
+ None,
+ EMAIL_USE_TLS
+ ],
+ 'telegram': [TELEGRAM_BOT_TOKEN],
+ 'vk': [VK_ACCESS_TOKEN],
+}
+
+SITEGATE_REMOTES = {
+ 'yandex': 'not_a_secret',
+ 'google': 'not_a_secret',
+}
+"""Идентификаторы клиентов для внешней авторизации."""
diff --git a/pythonz/settings/sub_logging.py b/pythonz/settings/sub_logging.py
new file mode 100644
index 00000000..73035988
--- /dev/null
+++ b/pythonz/settings/sub_logging.py
@@ -0,0 +1,72 @@
+import os
+
+from .sub_paths import PROJECT_DIR_STATE
+
+LOGGING = {
+ 'version': 1,
+ 'disable_existing_loggers': False,
+ 'formatters': {
+ 'simple': {
+ 'format': '%(asctime)s %(levelname)s %(name)s: %(message)s'
+ },
+ 'verbose': {
+ 'format': '%(asctime)s %(levelname)s %(name)s (%(module)s) (%(process)d / %(thread)d): %(message)s'
+ },
+ },
+ 'filters': {
+ 'require_debug_false': {
+ '()': 'django.utils.log.RequireDebugFalse',
+ },
+ 'require_debug_true': {
+ '()': 'django.utils.log.RequireDebugTrue',
+ },
+ },
+ 'handlers': {
+ 'mail_admins': {
+ 'level': 'ERROR',
+ 'filters': ['require_debug_false'],
+ 'class': 'django.utils.log.AdminEmailHandler'
+ },
+ 'console': {
+ 'level': 'DEBUG',
+ 'class': 'logging.StreamHandler',
+ 'formatter': 'simple'
+ },
+ 'null': {
+ 'class': 'logging.NullHandler',
+ },
+ },
+ 'loggers': {
+ 'django.db.backends': {
+ 'level': 'ERROR',
+ 'handlers': ['console'],
+ 'propagate': False,
+ },
+ 'django.security.DisallowedHost': {
+ 'handlers': ['null'],
+ 'propagate': False,
+ },
+ 'pythonz': {
+ 'level': 'DEBUG',
+ 'filters': ['require_debug_true'],
+ 'handlers': ['console'],
+ 'propagate': True,
+ },
+ 'pythonz.apps.management.commands': {
+ 'level': 'DEBUG',
+ 'handlers': ['console'],
+ 'propagate': False,
+ },
+ }
+}
+
+if not os.environ.get('PYTEST_VERSION'):
+ # запуск не из автотестов
+ LOGGING['handlers']['file'] = {
+ 'level': 'DEBUG',
+ 'class': 'logging.FileHandler',
+ 'filename': f"{PROJECT_DIR_STATE / 'debug.log'}",
+ 'formatter': 'verbose'
+ }
+
+LOGGERS = LOGGING['loggers']
diff --git a/pythonz/settings/sub_paths.py b/pythonz/settings/sub_paths.py
new file mode 100644
index 00000000..6b60374d
--- /dev/null
+++ b/pythonz/settings/sub_paths.py
@@ -0,0 +1,26 @@
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).absolute().parent.parent
+
+PROJECT_NAME = 'pythonz'
+PROJECT_DOMAIN = 'pythonz.net'
+
+PROJECT_DIR_STATE_LOCAL = BASE_DIR.parent / 'state'
+LOCAL_RUN = PROJECT_DIR_STATE_LOCAL.exists() or os.environ.get('PYTEST_VERSION')
+
+if LOCAL_RUN:
+ PROJECT_DIR_APP = BASE_DIR
+ PROJECT_DIR_STATE = PROJECT_DIR_STATE_LOCAL
+ PROJECT_DIR_RUN = PROJECT_DIR_STATE_LOCAL
+ PROJECT_DIR_CACHE = PROJECT_DIR_STATE_LOCAL
+
+else:
+ PROJECT_DIR_APP = Path('/srv') / PROJECT_NAME
+ PROJECT_DIR_STATE = Path('/var/lib') / PROJECT_NAME
+ PROJECT_DIR_RUN = Path('/run') / PROJECT_NAME
+ PROJECT_DIR_CACHE = Path('/var/cache') / PROJECT_NAME
+
+
+MEDIA_ROOT = f"{PROJECT_DIR_STATE / 'media'}/"
+STATIC_ROOT = f"{PROJECT_DIR_STATE / 'static'}/"
diff --git a/pythonz/settings/sub_security.py b/pythonz/settings/sub_security.py
new file mode 100644
index 00000000..c9aa2435
--- /dev/null
+++ b/pythonz/settings/sub_security.py
@@ -0,0 +1,32 @@
+PATH_CERTIFICATE = None
+CERTIFICATE_SELF_SIGNED = False
+
+SECRET_KEY = 'not_a_secret'
+
+
+SECURE_HSTS_SECONDS = 3600
+SECURE_HSTS_INCLUDE_SUBDOMAINS = True
+SECURE_CONTENT_TYPE_NOSNIFF = True
+SECURE_BROWSER_XSS_FILTER = True
+
+CSRF_COOKIE_SECURE = True
+SESSION_COOKIE_SECURE = True
+
+
+AUTH_USER_MODEL = 'apps.User'
+
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
diff --git a/pythonz/settings/sub_sentry.py b/pythonz/settings/sub_sentry.py
new file mode 100644
index 00000000..8fc97c6a
--- /dev/null
+++ b/pythonz/settings/sub_sentry.py
@@ -0,0 +1,17 @@
+import sentry_sdk
+from sentry_sdk.integrations.django import DjangoIntegration
+
+
+def init_sentry(*,dsn: str):
+ """Инициализирует машинерию Сентри.
+
+ :param dsn: Адрес для отправки событий.
+
+ """
+ sentry_sdk.init(
+ dsn=dsn,
+ integrations=[DjangoIntegration()],
+ traces_sample_rate=0.3,
+ send_default_pii=True,
+ max_breadcrumbs=10,
+ )
diff --git a/pythonz/urls.py b/pythonz/urls.py
new file mode 100644
index 00000000..964730b4
--- /dev/null
+++ b/pythonz/urls.py
@@ -0,0 +1,72 @@
+from sys import argv
+
+from django.conf import settings
+from django.conf.urls.static import static
+from django.contrib import admin
+from django.contrib.auth.views import (
+ LogoutView,
+ PasswordResetCompleteView,
+ PasswordResetConfirmView,
+ PasswordResetDoneView,
+ PasswordResetView,
+)
+from django.shortcuts import render
+from django.urls import include, path
+from robots.views import rules_list
+from sitegate.toolbox import get_sitegate_urls
+from sitemessage.toolbox import get_sitemessage_urls
+
+from .apps.realms import bootstrap_realms
+from .apps.views import index, login, page_not_found, permission_denied, search, server_error, telebot, user_settings
+
+urls_password_reset = [
+ path('password_reset/', PasswordResetView.as_view(), name='password_reset'),
+ path('password_reset/done/', PasswordResetDoneView.as_view(), name='password_reset_done'),
+ path('reset///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
+ path('reset/done/', PasswordResetCompleteView.as_view(), name='password_reset_complete'),
+]
+
+urlpatterns = [
+ path('', index, name='index'),
+ path('search/site/', render, {'template_name': 'static/search_site.html'}, name='search_site'),
+ path('search/', search, name='search'),
+ path('auth/', include(urls_password_reset)),
+ path('login/', login, name='login'),
+ path('settings/', user_settings, name='settings'),
+ path('logout/', LogoutView.as_view(), {'next_page': '/'}, name='logout'),
+ path('promo/', render, {'template_name': 'static/promo.html'}),
+ path('about/', render, {'template_name': 'static/about.html'}),
+ path('sitemap/', render, {'template_name': 'static/sitemap.html'}),
+ path('robots.txt', rules_list, name='robots_rule_list'),
+ path(f'{settings.TELEGRAM_BOT_URL}/', telebot),
+ path(f'{settings.ADMIN_URL}/', admin.site.urls),
+]
+
+urlpatterns += get_sitegate_urls() # Цепляем URLы от sitegate,
+urlpatterns += get_sitemessage_urls() # Цепляем URLы от sitemessage,
+
+# Используем собственные страницы ошибок.
+handler403 = permission_denied
+handler404 = page_not_found
+handler500 = server_error
+
+
+if settings.DEBUG:
+ # Чтобы работала отладочная панель.
+ import debug_toolbar
+ from django.urls import include, re_path
+
+ urlpatterns += [re_path(r'^__debug__/', include(debug_toolbar.urls)),]
+ # Чтобы статика раздавалась при runserver.
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+
+
+MIGRATION = len(argv) > 1 and argv[1] == 'migrate'
+"""
+Указание на то, что приложение вызвано из manage-скрипта с командой `migrate`.
+
+"""
+
+if not MIGRATION:
+ # Инициализируем области сайта.
+ bootstrap_realms(urlpatterns)
diff --git a/pythonz/uwsgicfg.py b/pythonz/uwsgicfg.py
new file mode 100644
index 00000000..ed78dff7
--- /dev/null
+++ b/pythonz/uwsgicfg.py
@@ -0,0 +1,53 @@
+from uwsgiconf.config import configure_uwsgi
+from uwsgiconf.presets.nice import PythonSection
+
+
+def get_configurations() -> PythonSection:
+
+ from django.conf import settings # noqa: PLC0415
+
+ in_production = settings.IN_PRODUCTION
+
+ project = settings.PROJECT_NAME
+ domain = settings.PROJECT_DOMAIN
+
+ dir_state = settings.PROJECT_DIR_STATE
+
+ section = PythonSection.bootstrap(
+ f'http://:{80 if in_production else 8000}',
+ allow_shared_sockets=in_production,
+
+ wsgi_module=f'{project}.wsgi',
+ process_prefix=f'[{project}] ',
+
+ workers=3,
+ threads=3,
+
+ log_dedicated=True,
+ ignore_write_errors=True,
+ touch_reload=f"{dir_state / 'reloader'}",
+ owner=project if in_production else None,
+ )
+
+ section.set_runtime_dir(f'{settings.PROJECT_DIR_RUN}')
+
+ section.main_process.change_dir(f'{dir_state}')
+ section.workers.set_reload_params(max_requests=10000)
+
+ section.spooler.add(f"{dir_state / 'spool'}")
+
+ if in_production and domain:
+ section.configure_certbot_https(
+ domain=domain,
+ webroot=f"{dir_state / 'certbot'}",
+ allow_shared_sockets=True)
+
+ section.configure_https_redirect()
+
+ section.configure_maintenance_mode(
+ f"{dir_state / 'maintenance'}", section.get_bundled_static_path('503.html'))
+
+ return section
+
+
+configure_uwsgi(get_configurations)
diff --git a/pythonz/wsgi.py b/pythonz/wsgi.py
new file mode 100644
index 00000000..1fde5ce9
--- /dev/null
+++ b/pythonz/wsgi.py
@@ -0,0 +1,6 @@
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pythonz.settings')
+application = get_wsgi_application()
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 314b2c16..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,23 +0,0 @@
-awesome-slugify==1.6.5
-Django==1.8.3
-Fabric==1.10.1
-Pillow==2.8.2
-bleach==1.4.1
-beautifulsoup4==4.3.2
-pytz==2015.4
-requests==2.7.0
-twitter==1.16.0
-django-admirarchy==0.2.0
-django-datetime-widget==0.9.3
-django-debug-toolbar==1.3.0
-django-etc==0.7.1
-django-simple-history==1.6.2
-django-siteblocks==0.5.0
-django-sitecats==0.5.0
-django-siteflags==0.4.0
-django-sitegate==0.10.1
-django-sitemessage==0.6.0
-django-sitemetrics==0.5.0
-django-siteprefs==0.5.0
-django-sitetree==1.4.0
-django-xross==0.4.0
\ No newline at end of file
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 00000000..e238d96b
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,64 @@
+target-version = "py313"
+line-length = 120
+extend-exclude = [
+ "migrations",
+]
+
+[format]
+quote-style = "single"
+exclude = []
+
+[lint]
+select = [
+ "B", # possible bugs
+ "BLE", # broad exception
+ "C4", # comprehensions
+ "DTZ", # work with datetimes
+ "E", # code style
+ "ERA", # commented code
+ "EXE", # check executables
+ "F", # misc
+ "FA", # future annotations
+ "FBT", # booleans
+ "FURB", # modernizing
+ "G", # logging format
+ "I", # imports
+ "ICN", # import conventions
+ "INT", # i18n
+ "ISC", # stringc concat
+ "PERF", # perfomance
+ "PIE", # misc
+ "PLC", # misc
+ "PLE", # misc err
+ "PT", # pytest
+ "PTH", # pathlib
+ "PYI", # typing
+ "RSE", # exc raise
+ "SLOT", # slots related
+ "TC", # typing
+ "UP", # py upgrade
+]
+
+ignore = []
+
+
+[lint.extend-per-file-ignores]
+
+"pythonz/settings/*" = [
+ "F403",
+ "F405",
+]
+
+"pythonz/apps/views/__init__.py" = [
+ "F401",
+]
+
+"pythonz/apps/models/__init__.py" = [
+ "F401",
+]
+
+"pythonz/apps/sitemessages.py" = [
+ "E731",
+ "PLC0415",
+]
+
diff --git a/runtests.sh b/runtests.sh
index 7e8968e2..c0ee1ab2 100755
--- a/runtests.sh
+++ b/runtests.sh
@@ -1,9 +1,21 @@
#!/bin/bash
+#
+# Run as follows:
+# ./runtests.sh
+# ./runtests.sh novenv
+#
+
+if [[ $1 != 'novenv' ]]; then
+
+ echo "Trying to use virtual environment '../venv' ..."
+
+ . .venv/bin/activate
+
+else
+
+ echo "Not using virtual environment ..."
-if [ $1 = 'venv' ];
-then
-. ../venv/bin/activate
fi
-./manage_dev.py test apps -t .
+pytest
diff --git a/settings/__init__.py b/settings/__init__.py
deleted file mode 100644
index 01da5cf1..00000000
--- a/settings/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-__author__ = 'idle'
diff --git a/settings/base.py b/settings/base.py
deleted file mode 100644
index 5f96c1ef..00000000
--- a/settings/base.py
+++ /dev/null
@@ -1,139 +0,0 @@
-#
-# Для конфигурирования в ходе разработки используйте dev.py, а не этот файл.
-#
-from os.path import dirname
-
-from django.conf.global_settings import TEMPLATE_CONTEXT_PROCESSORS
-
-
-PATH_PROJECT = dirname(dirname(__file__))
-PATH_DATA = '%s/data' % PATH_PROJECT
-
-####################################################################
-
-SITE_URL = 'http://pythonz.net'
-
-PROJECT_SOURCE_URL = 'https://github.com/idlesign/pythonz'
-DEBUG = True
-
-SECRET_KEY = 'not_a_secret'
-ADMINS = ()
-
-DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.sqlite3',
- 'NAME': '%s/db/data.db' % PATH_DATA,
- }
-}
-
-# Сюда помещаются реквизиты для пользования соответствующими службами доставки сообщений (cм. sitemessages.py).
-SITEMESSAGES_SETTINGS = {
- 'twitter': [],
- 'smtp': []
-}
-
-
-# Здесь указываются партнёрские идентификаторы.
-PARTNER_IDS = {}
-
-
-GOOGLE_API_KEY = 'not_a_secret'
-YANDEX_RCA_KEY = 'not_a_secret'
-
-# Переводит проект в агрессивный режим: задействует различную машинерию для привлечения внимания к проекту.
-AGRESSIVE_MODE = False
-
-####################################################################
-
-SITEMESSAGE_INIT_BUILTIN_MESSAGE_TYPES = False
-SITEMESSAGE_DEFAULT_SHORTCUT_EMAIL_MESSAGES_TYPE = 'simple'
-
-MANAGERS = ADMINS
-
-SITE_ID = 1
-
-AUTH_USER_MODEL = 'apps.User'
-
-TIME_ZONE = 'Asia/Novosibirsk'
-LANGUAGE_CODE = 'ru'
-ROOT_URLCONF = 'urls'
-WSGI_APPLICATION = 'wsgi.application'
-
-TEST_RUNNER = 'django.test.runner.DiscoverRunner'
-
-USE_I18N = True
-USE_L10N = True
-USE_TZ = True
-
-ADMIN_URL = 'admin'
-
-MEDIA_ROOT = '%s/media/' % PATH_DATA
-MEDIA_URL = '/media/'
-
-STATIC_ROOT = '%s/static/' % PATH_DATA
-STATIC_URL = '/static/'
-STATICFILES_DIRS = ('%s/static_src/' % PATH_DATA,)
-STATICFILES_FINDERS = (
- 'django.contrib.staticfiles.finders.FileSystemFinder',
- 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
-)
-
-
-MIDDLEWARE_CLASSES = (
- 'django.middleware.common.CommonMiddleware',
- 'django.contrib.sessions.middleware.SessionMiddleware',
- 'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.messages.middleware.MessageMiddleware',
- 'django.middleware.csrf.CsrfViewMiddleware',
- 'django.middleware.clickjacking.XFrameOptionsMiddleware',
- 'django.middleware.locale.LocaleMiddleware',
- 'apps.middleware.TimezoneMiddleware',
- 'simple_history.middleware.HistoryRequestMiddleware',
-)
-
-
-TEMPLATE_DEBUG = DEBUG
-TEMPLATE_LOADERS = (
- 'django.template.loaders.filesystem.Loader',
- 'django.template.loaders.app_directories.Loader',
-)
-TEMPLATE_CONTEXT_PROCESSORS += ('django.core.context_processors.request',)
-
-
-INSTALLED_APPS = (
- 'django.contrib.auth',
- 'django.contrib.contenttypes',
- 'django.contrib.sessions',
- 'django.contrib.sites',
- 'django.contrib.sitemaps',
- 'django.contrib.messages',
- 'django.contrib.staticfiles',
- 'django.contrib.admin',
-
- 'apps',
-
- 'admirarchy',
- 'sitecats',
- 'siteflags',
- 'sitetree',
- 'siteblocks',
- 'sitegate',
- 'sitemetrics',
- 'siteprefs',
- 'sitemessage',
- 'xross',
- 'etc',
-
- 'datetimewidget',
- 'simple_history',
-)
-
-
-if DEBUG:
- # Обход ошибки импорта - ручное конфигурирование отладочной панели.
- # https://github.com/django-debug-toolbar/django-debug-toolbar/issues/521.
- DEBUG_TOOLBAR_PATCH_SETTINGS = False
- MIDDLEWARE_CLASSES = ('debug_toolbar.middleware.DebugToolbarMiddleware',) + MIDDLEWARE_CLASSES
- INSTALLED_APPS += ('debug_toolbar',)
- # INSTALLED_APPS += ('debug_toolbar.apps.DebugToolbarConfig',)
- INTERNAL_IPS = ['127.0.0.1']
diff --git a/settings/dev.py b/settings/dev.py
deleted file mode 100644
index 3d5a7ce2..00000000
--- a/settings/dev.py
+++ /dev/null
@@ -1,7 +0,0 @@
-#
-# Для конфигурирования в ходе разработки используйте этот файл, а не base.py
-#
-from .base import *
-
-
-ADMINS = (('me', 'me@some.where'),)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..8fb915df
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,25 @@
+import os
+
+import pytest
+from pytest_djangoapp import configure_djangoapp_plugin
+
+# Используем имитатор вместо uwsgi.
+os.environ['UWSGICONF_FORCE_STUB'] = '1'
+
+pytest_plugins = configure_djangoapp_plugin(
+ settings='pythonz.settings.env_testing',
+ admin_contrib=True,
+ migrate=False,
+)
+
+
+@pytest.fixture
+def robot(user_create, settings):
+ """Возвращает объект пользователя-робота (суперпользователь)."""
+ return user_create(attributes={'id': settings.ROBOT_USER_ID}, superuser=True)
+
+
+@pytest.fixture
+def mock_get_location(monkeypatch):
+ monkeypatch.setattr(
+ 'pythonz.apps.models.place.get_location_data', lambda location_name: {})
diff --git a/tests/integration/resources.py b/tests/integration/resources.py
new file mode 100644
index 00000000..d8a15f0b
--- /dev/null
+++ b/tests/integration/resources.py
@@ -0,0 +1,9 @@
+import pytest
+
+from pythonz.apps.integration.resources import PyDigestResource
+
+
+@pytest.mark.slow
+def test_py_digest():
+ entries = PyDigestResource.fetch_entries()
+ assert entries
diff --git a/tests/integration/test_integr_utils.py b/tests/integration/test_integr_utils.py
new file mode 100644
index 00000000..b8b91bae
--- /dev/null
+++ b/tests/integration/test_integr_utils.py
@@ -0,0 +1,45 @@
+import json
+
+import pytest
+
+from pythonz.apps.integration.utils import get_location_data, get_page_info
+
+
+@pytest.mark.slow
+def test_get_page_info():
+ info = get_page_info('https://pythonz.net/videos/127/')
+ assert info is None
+
+
+def test_get_location_data(response_mock):
+
+ json_response = json.dumps({'response': {
+ 'GeoObjectCollection': {
+ 'metaDataProperty': {'GeocoderResponseMetaData': {'found': 1}},
+ 'featureMember': [
+ {'GeoObject': {
+ 'Point': {'pos': 'a b'},
+ 'boundedBy': {'Envelope': {'lowerCorner': '1', 'upperCorner': '2'}},
+ 'metaDataProperty': {
+ 'GeocoderMetaData': {
+ 'kind': 'xxx',
+ 'text': 'yyy',
+ 'AddressDetails': {'Country': {'CountryName': 'zzz'}}
+ }
+ }
+ }}
+ ]
+ }
+ }})
+
+ with response_mock(f'GET https://geocode-maps.yandex.ru/1.x/ -> 200 :{json_response}'):
+ result = get_location_data('Some')
+
+ assert result == {
+ 'requested_name': 'Some',
+ 'type': 'xxx',
+ 'name': 'yyy',
+ 'country': 'zzz',
+ 'pos': 'b,a',
+ 'bounds': '1|2'
+ }
diff --git a/tests/integration/test_partners.py b/tests/integration/test_partners.py
new file mode 100644
index 00000000..eba6d315
--- /dev/null
+++ b/tests/integration/test_partners.py
@@ -0,0 +1,80 @@
+from unittest.mock import Mock
+
+import pytest
+
+from pythonz.apps.integration import partners
+from pythonz.apps.integration.partners import get_partner_links
+from pythonz.apps.models import PartnerLink
+from pythonz.apps.realms import Book, BookRealm
+
+
+@pytest.fixture
+def assert_link_data():
+
+ def assert_link_data_(partner_cls, url):
+ partner = partner_cls(partner_id='dummy')
+
+ data = partner.get_link_data(Mock(), PartnerLink(url=url))
+
+ assert "руб" in data['price']
+ assert 'dummy' in data['url']
+
+ return data
+
+ return assert_link_data_
+
+
+@pytest.mark.slow
+def test_get_links(robot):
+
+ book = Book()
+ book.save()
+
+ urls = [
+ ('booksru', 'https://www.books.ru/books/bibliya-705085/'),
+ ('litres', 'https://www.litres.ru/mark-summerfield/programmirovanie-na-python-3-podrobnoe-rukovodstvo-24499518/'),
+ ]
+
+ links = []
+
+ for alias, url in urls:
+ link = PartnerLink(url=url, partner_alias=alias)
+ link.linked_object = book
+ link.save()
+ links.append(link)
+
+ data = get_partner_links(BookRealm, book)
+ assert len(data['links']) == 2
+
+
+@pytest.mark.slow
+def test_booksru(assert_link_data):
+
+ data = assert_link_data(
+ partners.BooksRu, 'https://www.books.ru/books/kapital-4985956/')
+
+ assert 'https://favicon.yandex.net/favicon/books.ru' == data['icon_url']
+
+
+@pytest.mark.slow
+def test_litres(assert_link_data):
+
+ assert_link_data(
+ partners.LitRes,
+ 'https://www.litres.ru/book/karl-marks/nischeta-filosofii-63109882/')
+
+
+@pytest.mark.slow
+def test_bookvoed(assert_link_data):
+
+ assert_link_data(
+ partners.Bookvoed,
+ 'https://www.bookvoed.ru/product/kapital-5139682')
+
+
+@pytest.mark.slow
+def test_book24(assert_link_data):
+
+ assert_link_data(
+ partners.Book24,
+ 'https://book24.ru/product/legkiy-sposob-vyuchit-python-3-2209997/')
diff --git a/tests/integration/test_peps.py b/tests/integration/test_peps.py
new file mode 100644
index 00000000..143a49e6
--- /dev/null
+++ b/tests/integration/test_peps.py
@@ -0,0 +1,31 @@
+import pytest
+
+from pythonz.apps.integration.peps import strip_mail, sync
+
+
+def test_strip_mail():
+
+ result = strip_mail('Victor Stinner ')
+
+ assert result == ['Victor Stinner']
+
+ result = strip_mail('Barry Warsaw, Jeremy Hylton, David Goodger, Nick Coghlan')
+
+ assert result == ['Barry Warsaw', 'Jeremy Hylton', 'David Goodger', 'Nick Coghlan']
+
+ result = strip_mail('paul at prescod.net (Paul Prescod)')
+
+ assert result == ['Paul Prescod']
+
+ result = strip_mail(
+ 'Guido van Rossum , '
+ 'Barry Warsaw , '
+ 'Nick Coghlan ')
+
+ assert result == ['Guido van Rossum', 'Barry Warsaw', 'Nick Coghlan']
+
+
+@pytest.mark.slow
+def test_sync(robot):
+ synced = sync(limit=3, skip_finalized=True)
+ assert len(synced) == 3
diff --git a/tests/integration/test_pypistats.py b/tests/integration/test_pypistats.py
new file mode 100644
index 00000000..9eb14421
--- /dev/null
+++ b/tests/integration/test_pypistats.py
@@ -0,0 +1,11 @@
+import pytest
+
+from pythonz.apps.integration.pypistats import get_for_package
+
+
+@pytest.mark.slow
+def test_basic():
+
+ data = get_for_package('django-sitetree')
+ assert data
+ assert data.popitem()[1] > 0
diff --git a/tests/integration/test_source_base.py b/tests/integration/test_source_base.py
new file mode 100644
index 00000000..c39a342d
--- /dev/null
+++ b/tests/integration/test_source_base.py
@@ -0,0 +1,32 @@
+from pythonz.apps.integration.base import RemoteSource
+
+
+class SourceGroup1(RemoteSource):
+
+ realm = 'group1'
+
+
+class SourceGroup2(RemoteSource):
+
+ realm = 'group2'
+
+
+class Src1Grp1(SourceGroup1):
+
+ alias = 'one'
+
+
+class Src2Grp1(SourceGroup1):
+
+ alias = 'two'
+
+
+def test_sources():
+
+ assert list(SourceGroup1.get_sources().keys()) == ['one', 'two']
+ assert list(SourceGroup2.get_sources().keys()) == []
+
+ assert SourceGroup1.get_source('one') is Src1Grp1
+
+ enum = SourceGroup1.get_enum()
+ assert enum.values == ['one', 'two']
diff --git a/tests/integration/test_summary.py b/tests/integration/test_summary.py
new file mode 100644
index 00000000..4b2fe78d
--- /dev/null
+++ b/tests/integration/test_summary.py
@@ -0,0 +1,132 @@
+import pytest
+from django.utils import timezone
+
+from pythonz.apps.integration.summary import Discuss, GithubTrending, Lwn, MailarchConferences, Psf, Stackoverflow
+
+pytestmark = [pytest.mark.slow]
+
+
+def test_lwn():
+
+ results, latest = Lwn(
+ previous_result=[],
+ previous_dt=None
+ ).run()
+
+ assert isinstance(results, list)
+ assert isinstance(latest, dict)
+ assert results
+
+ results, latest = Lwn(
+ previous_result=latest,
+ previous_dt=None
+ ).run()
+ assert isinstance(results, list)
+ assert isinstance(latest, dict)
+ assert not results
+
+
+def test_pipermail():
+
+ current, latest = MailarchConferences(
+ previous_result=[],
+ previous_dt=None,
+ year_month='2009-May'
+ ).run()
+
+ assert len(current) == 7
+ assert not current[0].title.startswith('[')
+ assert len(latest) == 1
+ assert latest[0] == 'https://mail.python.org/pipermail/conferences/2009-May/000016.html'
+
+ prev_result = ['https://mail.python.org/pipermail/conferences/2009-May/000009.html']
+
+ current, latest = MailarchConferences(
+ previous_result=prev_result,
+ previous_dt=None,
+ year_month='2009-May'
+ ).run()
+
+ assert len(current) == 4
+ assert len(latest) == 1
+
+ MailarchConferences(
+ previous_result=['faked'],
+ previous_dt=None
+ ).run()
+
+ assert len(latest) == 1
+
+
+def test_discuss():
+
+ current, latest = Discuss(
+ previous_result=[],
+ previous_dt=None,
+ ).run()
+
+ assert current
+ assert latest
+
+
+def test_psf():
+
+ current, latest = Psf(
+ previous_result=[],
+ previous_dt=None,
+ ).run()
+
+ assert current
+ assert latest
+
+ current_2, latest_2 = Psf(
+ previous_result=latest,
+ previous_dt=None,
+ ).run()
+
+ assert latest == latest_2
+ assert current_2 == []
+
+
+def test_github():
+
+ current, latest = GithubTrending(
+ previous_result=[],
+ previous_dt=None
+ ).run()
+
+ assert current
+
+ len_latest = len(latest)
+ len_current = len(current)
+
+ assert len_latest == len_current
+
+ one_latest = latest.pop()
+
+ current, latest = GithubTrending(
+ previous_result=latest,
+ previous_dt=None
+ ).run()
+
+ assert len(current) == 1
+ assert len(latest) == 1
+ assert one_latest in latest
+
+
+@pytest.mark.skip('403 ошибка при получении csv')
+def test_stack():
+
+ current, latest = Stackoverflow(
+ previous_result=[],
+ previous_dt=None
+ ).run()
+
+ assert len(latest)
+
+ current, latest = Stackoverflow(
+ previous_result=[],
+ previous_dt=timezone.now()
+ ).run()
+
+ assert not len(latest) # за сегодня ещё нет вопросов
diff --git a/tests/integration/test_videos.py b/tests/integration/test_videos.py
new file mode 100644
index 00000000..7a6e3971
--- /dev/null
+++ b/tests/integration/test_videos.py
@@ -0,0 +1,15 @@
+from pythonz.apps.integration.videos import VideoBroker
+
+
+def test_youtube():
+
+ img_expected = 'http://img.youtube.com/vi/WW0DTSioHQU/default.jpg'
+
+ emb, img = VideoBroker.get_data_from_youtube('https://youtu.be/WW0DTSioHQU')
+
+ assert 'embed' in emb
+ assert img == img_expected
+
+ emb, img = VideoBroker.get_data_from_youtube('https://youtu.be/WW0DTSioHQU?some=1&other=2')
+ assert 'embed' in emb
+ assert img == img_expected
diff --git a/tests/models/test_app.py b/tests/models/test_app.py
new file mode 100644
index 00000000..95e624ad
--- /dev/null
+++ b/tests/models/test_app.py
@@ -0,0 +1,20 @@
+import pytest
+
+from pythonz.apps.models import App
+
+
+@pytest.mark.slow
+def test_basic(robot):
+
+ app1 = App.objects.create(slug='pytest-djangoapp', submitter=robot, status=App.Status.PUBLISHED)
+ app2 = App.objects.create(slug='srptools', submitter=robot, status=App.Status.PUBLISHED)
+
+ updated = App.actualize_downloads()
+
+ assert updated == 2
+
+ app1.refresh_from_db()
+ app2.refresh_from_db()
+
+ assert app1.downloads
+ assert app2.downloads
diff --git a/tests/models/test_model_book.py b/tests/models/test_model_book.py
new file mode 100644
index 00000000..bfb74a5f
--- /dev/null
+++ b/tests/models/test_model_book.py
@@ -0,0 +1,34 @@
+from pythonz.apps.models import Book
+
+
+def test_partner_links_enrich(robot):
+ data = {
+ 'первая': {
+ 'здесь1': 'booksru',
+ 'здесь2': 'booksru',
+ 'там1': 'litres',
+ },
+ }
+ links = Book.partner_links_enrich(data)
+
+ assert Book.objects.count() == 1
+
+ assert len(links) == 3
+ link = links[0]
+ assert link.url == 'здесь1'
+ assert link.partner_alias == 'booksru'
+
+ data.update({
+ 'первая': {
+ 'здесь3': 'booksru', # добавится эта ссылка.
+ },
+ 'тоже первая': {
+ 'здесь2': 'booksru',
+ },
+ 'третья': { # и эта книга.
+ 'там2': 'litres', # и эта ссылка
+ },
+ })
+ links = Book.partner_links_enrich(data)
+ assert Book.objects.count() == 2
+ assert len(links) == 2
diff --git a/tests/models/test_model_event.py b/tests/models/test_model_event.py
new file mode 100644
index 00000000..d7c6158b
--- /dev/null
+++ b/tests/models/test_model_event.py
@@ -0,0 +1,17 @@
+import pytest
+from django.db import IntegrityError
+
+from pythonz.apps.models import Event
+
+
+def test_event_unique_source(robot):
+ Event.objects.create(submitter=robot, src_alias='one', src_id='10')
+
+ with pytest.raises(IntegrityError):
+ Event.objects.create(submitter=robot, src_alias='one', src_id='10')
+
+
+@pytest.mark.slow
+def test_event_fetch_items(robot, mock_get_location):
+ Event.fetch_items()
+ assert Event.objects.first().time_published
diff --git a/tests/models/test_model_external.py b/tests/models/test_model_external.py
new file mode 100644
index 00000000..24f6447c
--- /dev/null
+++ b/tests/models/test_model_external.py
@@ -0,0 +1,8 @@
+import pytest
+
+from pythonz.apps.models import ExternalResource
+
+
+@pytest.mark.slow
+def test_external():
+ ExternalResource.fetch_new()
diff --git a/tests/models/test_model_pep.py b/tests/models/test_model_pep.py
new file mode 100644
index 00000000..b1331177
--- /dev/null
+++ b/tests/models/test_model_pep.py
@@ -0,0 +1,16 @@
+from pythonz.apps.models import PEP
+
+
+def test_pep(robot):
+
+ pep1 = PEP(num=1)
+ pep1.save()
+
+ pep9999 = PEP(num=9999)
+ pep9999.save()
+
+ assert pep1.get_link_to_pyorg() == 'https://www.python.org/dev/peps/pep-0001/'
+ assert pep9999.get_link_to_pyorg() == 'https://www.python.org/dev/peps/pep-9999/'
+
+ assert pep1.get_absolute_url(with_prefix=True) == 'https://pythonz.net/peps/named/0001/'
+ assert pep9999.get_absolute_url(with_prefix=True) == 'https://pythonz.net/peps/named/9999/'
diff --git a/tests/models/test_model_person.py b/tests/models/test_model_person.py
new file mode 100644
index 00000000..90c4612d
--- /dev/null
+++ b/tests/models/test_model_person.py
@@ -0,0 +1,26 @@
+from pythonz.apps.models import Person
+
+
+def test_person():
+
+ known = {}
+
+ person = Person(
+ name='Натаниэль Смит',
+ name_en='Nathaniel Smith',
+ aka='Nathaniel J. Smith; N.J. Smith',
+ )
+ Person.contribute_to_known_persons(person, known)
+
+ assert set(known.keys()) == {
+ 'Смит Натаниэль',
+ 'Smith N.J.',
+ 'N. Smith',
+ 'N.J. Smith',
+ 'Smith Nathaniel',
+ 'Натаниэль Смит',
+ 'Н. Смит',
+ 'Nathaniel Smith',
+ 'Nathaniel J. Smith',
+ 'N. J. Smith'
+ }
diff --git a/tests/models/test_model_reference.py b/tests/models/test_model_reference.py
new file mode 100644
index 00000000..66d0f853
--- /dev/null
+++ b/tests/models/test_model_reference.py
@@ -0,0 +1,18 @@
+from pythonz.apps.models import Reference
+
+
+def test_reference_types(robot):
+
+ ref = Reference.objects.create(
+ title='func', type=Reference.Type.FUNCTION, submitter=robot)
+
+ assert ref.is_type_callable # Enum
+ ref.refresh_from_db()
+ assert ref.is_type_callable # Int
+
+ ref = Reference.objects.create(
+ title='chap', type=Reference.Type.CHAPTER, submitter=robot)
+
+ assert ref.is_type_bundle
+ ref.refresh_from_db()
+ assert ref.is_type_bundle
diff --git a/tests/models/test_model_summary.py b/tests/models/test_model_summary.py
new file mode 100644
index 00000000..6fe531d2
--- /dev/null
+++ b/tests/models/test_model_summary.py
@@ -0,0 +1,16 @@
+from pythonz.apps.integration.summary.base import SummaryItem
+from pythonz.apps.models import Category, Summary
+
+
+def test_create_article(robot):
+ Category(creator=robot).save()
+ article = Summary.create_article({
+ 'psf': [
+ SummaryItem('https://some.iii/a/b', 'This| nice', 'do ` best'),
+ SummaryItem('https://other.iii/d/f', 'title', 'descr'),
+ ]
+ })
+ assert article
+ assert article.text_src == (
+ '.. title:: Блог PSF\n.. table::\n'
+ '`This nice`_ — do best\n`title`_ — descr\n\n\n')
diff --git a/tests/models/test_model_vacancy.py b/tests/models/test_model_vacancy.py
new file mode 100644
index 00000000..5cff9ef3
--- /dev/null
+++ b/tests/models/test_model_vacancy.py
@@ -0,0 +1,16 @@
+import pytest
+from django.db import IntegrityError
+
+from pythonz.apps.models import Vacancy
+
+
+def test_vacancy_unique_source(robot):
+ Vacancy.objects.create(submitter=robot, src_alias='one', src_id='10')
+
+ with pytest.raises(IntegrityError):
+ Vacancy.objects.create(submitter=robot, src_alias='one', src_id='10')
+
+
+@pytest.mark.slow
+def test_vacancy_fetch_items(robot, mock_get_location):
+ Vacancy.fetch_items()
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 00000000..84f4836f
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,177 @@
+from pythonz.apps.utils import BasicTypograph, PersonName, TextCompiler, swap_layout, url_mangle
+
+
+def test_person_name():
+
+ name = PersonName('Иван Иванов')
+
+ assert name.first_last == 'Иван Иванов'
+ assert name.last_first == 'Иванов Иван'
+ assert name.full == 'Иван Иванов'
+ assert name.short == 'И. Иванов'
+ assert name.first == 'Иван'
+ assert name.last == 'Иванов'
+ assert name.is_valid
+ assert len(name.get_variants) == 3
+
+ name = PersonName('Guido van Rossum')
+
+ assert name.first_last == 'Guido Rossum'
+ assert name.last_first == 'Rossum Guido'
+ assert name.full == 'Guido van Rossum'
+ assert name.short == 'G. van Rossum'
+ assert name.first == 'Guido'
+ assert name.last == 'Rossum'
+ assert name.is_valid
+ assert len(name.get_variants) == 4
+
+ name = PersonName('Натаниэль Дж. Смит')
+
+ assert name.first_last == 'Натаниэль Смит'
+ assert name.last_first == 'Смит Натаниэль'
+ assert name.full == 'Натаниэль Дж. Смит'
+ assert name.short == 'Н. Дж. Смит'
+ assert name.first == 'Натаниэль'
+ assert name.last == 'Смит'
+ assert name.is_valid
+ assert len(name.get_variants) == 4
+
+ name = PersonName('Петров')
+
+ assert name.first_last == ''
+ assert name.last_first == ''
+ assert name.full == ''
+ assert name.short == ''
+ assert name.first == ''
+ assert name.last == ''
+ assert not name.is_valid
+ assert name.get_variants == []
+
+
+def test_url_mangle():
+
+ assert url_mangle('http://some.com/not/very/long/url') == 'http://some.com/not/very/long/url'
+ assert url_mangle('http://some.com/path/to/some/resource/which/ends?with=this#stuff') == 'http://some.com/<...>ends'
+ assert url_mangle('http://some.com/') == 'http://some.com/'
+ assert url_mangle('http://some.com'), 'http://some.com'
+
+
+def test_typography():
+
+ input_str = ("Мама ''мыла'' раму. "
+ 'Фабрика “Красная Заря”. '
+ '"Маме - раму!",- кричал\tИван. '
+ 'Температура повысилась на 7-8 градусов. '
+ '(c), (r), (tm) заменяем на правильные. '
+ '"строка\nперенесена')
+
+ expected_str = ('Мама «мыла» раму. '
+ 'Фабрика «Красная Заря». '
+ '«Маме — раму!»,— кричал Иван. '
+ 'Температура повысилась на 7–8 градусов. '
+ '©, ®, ™ заменяем на правильные. '
+ '«строка\nперенесена')
+
+ assert BasicTypograph.apply_to(input_str) == expected_str
+
+
+def test_text_compiler():
+
+ compile = TextCompiler.compile
+
+ assert compile('- ``func(*args, **kwargs)`` -') == '- func(*args, **kwargs) -'
+ assert compile(" myfunc(*[1], **{'three': 'some'})") == " myfunc(*[1], **{'three': 'some'})"
+
+ assert (
+ 'is https://some.org ' in
+ compile('.. table::\n`one / two`_ - is https://some.org\nanother\n\n\n')
+ )
+
+ assert compile('2 ** 10d') == '2 ** 10d'
+
+ assert (
+ compile('``zip(*[iter(s)] * n)``\nlist(zip(*[iter(seq)] * 2)) # [(1, 2), (3, 4), (5, 6)]') ==
+ 'zip(*[iter(s)] * n) list(zip(*[iter(seq)] * 2)) # [(1, 2), (3, 4), (5, 6)]')
+
+ assert compile('2 ** 10d') == '2 ** 10d'
+
+ assert compile('**some**') == 'some '
+ assert compile('*some*') == 'some '
+ assert compile('**1.** пункт') == '1. пункт'
+ assert compile('```\nздесь цитата\n```') == 'здесь цитата '
+
+ assert compile('``some``') == 'some'
+
+ assert compile('http://some.url/') == 'http://some.url/ '
+ assert compile(
+ '`This is httpserver link `_') == 'This is httpserver link '
+ assert (
+ compile('Пробуем `ссылку с [именем] `_.') ==
+ 'Пробуем ссылку с [именем] .')
+
+ assert compile('\n* Some.\n\n') == ' '
+ assert compile('\n* Some.\n* Other.\n\n') == ' '
+
+ assert (
+ compile('.. gist:: someuser/gisthashhere\n') ==
+ '')
+
+ assert (
+ compile('.. video:: https://youtu.be/ZE7WsnmGZ3U?t=10\n') ==
+ ''
+ '
')
+
+ assert (
+ compile('.. podster:: http://mtpod.podster.fm/0\n') ==
+ '')
+
+ assert (
+ compile('.. image:: http://some.url/img.png\n') ==
+ ' ')
+
+ assert (
+ compile('.. code:: python\nprint("some")\n\n\n') ==
+ 'print("some") ')
+
+ assert (
+ compile('.. code:: html\nprint("some")\n\n\n') ==
+ 'print("some") ')
+
+ assert (
+ compile('.. table::\n``x(? ')
+
+ assert (
+ compile('.. table::\n! 1 | 2 | 3\n4 | 5 | 6\n\n\n') ==
+ '
')
+
+ assert (
+ compile('.. table::\n!b:d+ 1 | 2 | 3 \n 4 | !b:i 5 | 6\n\n\n') ==
+ '
')
+
+ assert (
+ compile('.. note:: a note\n') ==
+ '
')
+
+ assert (
+ compile('.. warning:: a warn\n') ==
+ '
')
+
+ compiled = compile('.. poll:: absdefgh\n\n\n')
+ assert 'absdefgh' in compiled
+ assert 'yastatic' in compiled
+
+
+def test_swap_layout():
+ assert swap_layout('вуа') == 'def'
+ assert not swap_layout('def')
+ assert swap_layout('Ш рфму ыуут ьщку ерфт ьщыею') == 'I have seen more than most.'
diff --git a/tests/views/test_smoke.py b/tests/views/test_smoke.py
new file mode 100644
index 00000000..5e17fd15
--- /dev/null
+++ b/tests/views/test_smoke.py
@@ -0,0 +1,304 @@
+import pytest
+from django.core.exceptions import FieldDoesNotExist
+from django.utils import timezone
+from sitecats.models import ModelWithCategory
+
+from pythonz.apps.realms import PEP, Article, Category, get_realm
+
+
+@pytest.fixture
+def check_page(request_client):
+
+ def check(view, *, assertions, args=None, user=None, data: dict | None = None):
+
+ client = request_client(user=user)
+
+ kwargs = {}
+ if data is None:
+ method = client.get
+ else:
+ method = client.post
+ kwargs['data'] = data
+
+ response = method(
+ (view, args),
+ **kwargs,
+ follow=True
+ )
+ content = response.content.decode()
+
+ for assertion in assertions:
+ assert assertion in content, content
+
+ return content
+
+ return check
+
+
+@pytest.fixture
+def init_category(robot, monkeypatch):
+
+ def init_category_(*, title):
+ category, _ = Category.objects.get_or_create(
+ creator=robot,
+ title=title,
+ )
+ return category
+
+ return init_category_
+
+
+@pytest.fixture
+def check_realm(check_page, robot, monkeypatch, request_client, init_category):
+
+ def check_realm_(alias, *, views_hooks: dict | None = None, obj_kwargs=None, add_kwargs=None):
+
+ views_hooks = views_hooks or {}
+ obj_kwargs = obj_kwargs or {}
+ add_kwargs = add_kwargs or {}
+
+ realm = get_realm(alias)
+
+ model = realm.model
+
+ obj_title = f'{model._meta.verbose_name} заголовок'
+
+ obj_kwargs_base = {
+ 'submitter': robot,
+ }
+
+ try:
+ model._meta.get_field('title')
+ obj_kwargs_base['title'] = obj_title
+
+ except FieldDoesNotExist:
+ pass
+
+ if 'status' not in obj_kwargs:
+ obj_kwargs_base['status'] = model.Status.PUBLISHED
+
+ obj = model.objects.create(**{**obj_kwargs, **obj_kwargs_base})
+
+ supports_categories = issubclass(model, ModelWithCategory)
+
+ if supports_categories:
+ category_1 = init_category(title='catone')
+ obj.add_to_category(category_1, user=robot)
+
+ if 'listing' in realm.allowed_views:
+
+ hook = views_hooks.get('listing')
+ checks = []
+
+ if supports_categories:
+ checks.append(f'small">{category_1.title}')
+
+ not hook and checks.extend([
+ f'{model._meta.verbose_name_plural}<',
+ f'{obj_title}',
+ ])
+
+ result = check_page(
+ realm.get_listing_urlname(),
+ assertions=checks)
+
+ hook and hook(result)
+
+ if 'details' in realm.allowed_views:
+
+ hook = views_hooks.get('details')
+ checks = []
+
+ not hook and checks.extend([
+ f'{obj_title}<',
+ ])
+
+ result = check_page(
+ realm.get_details_urlname(),
+ args={'obj_id': obj.id},
+ assertions=checks)
+
+ hook and hook(result)
+
+ if 'add' in realm.allowed_views and add_kwargs:
+
+ add_kwargs.update({
+ 'pythonz_form': '1',
+ '__submit': 'siteform',
+ })
+
+ hook = views_hooks.get('add')
+ checks = []
+
+ not hook and checks.extend([
+ 'title="Добавил: ', # страница деталей содержит этот текст
+
+ ])
+
+ result = check_page(
+ realm.get_add_urlname(),
+ data=add_kwargs,
+ args={},
+ assertions=checks, user=robot)
+
+ hook and hook(result)
+
+ if 'edit' in realm.allowed_views:
+
+ hook = views_hooks.get('edit')
+ checks = []
+
+ not hook and checks.extend([
+ 'Выход',
+ f'{realm.txt_form_edit}',
+ ])
+
+ result = check_page(
+ realm.get_edit_urlname(),
+ args={'obj_id': obj.id},
+ assertions=checks, user=robot)
+
+ hook and hook(result)
+
+ if realm.syndication_enabled:
+ client = request_client(user=None)
+ content = client.get(f'/{realm.name_plural}/feed/').content.decode()
+ assert f'
{realm.name}_{obj.id} ' in content
+
+ return check_realm_
+
+
+def test_index(check_page):
+
+ check_page('index', assertions=[
+ 'Вход',
+ 'Статьи',
+ 'Про Python',
+ ])
+
+
+def test_search_site(check_page):
+
+ check_page('search_site', assertions=[
+ 'Поиск по сайту',
+ ])
+
+
+def test_search(check_page):
+
+ check_page('search', assertions=[
+ 'Про Python', # Перенаправление на главную.
+ ])
+
+
+def test_login(check_page):
+
+ check_page('login', assertions=[
+ 'Вход',
+ ])
+
+
+def test_logout(check_page, robot):
+
+ check_page('logout', assertions=[
+ 'Про Python', # Перенаправление на главную.
+ ], data = {}, user=robot)
+
+
+def test_settings(check_page, robot):
+
+ check_page('settings', assertions=[
+ 'Выход',
+ 'Настройки',
+ ], user=robot)
+
+
+def test_robots(check_page):
+
+ check_page('robots_rule_list', assertions=[
+ 'User-agent',
+ ])
+
+
+def test_books(check_realm):
+ check_realm('book')
+
+
+def test_videos(check_realm):
+ check_realm('video')
+
+
+def test_events(check_realm, request_client):
+
+ check_realm('event', add_kwargs={
+ 'title': 'one',
+ 'type': '1',
+ 'specialization': '1',
+ 'description': '3123',
+ 'text_src': 'qwreewr',
+ })
+
+
+def test_vacancies(check_realm):
+ check_realm('vacancy')
+
+
+@pytest.mark.skip('Разобраться с resolve, использующим локаль') # todo
+def test_references(check_realm):
+ check_realm('reference')
+
+
+def test_articles(check_realm):
+ check_realm('article')
+
+
+def test_places(check_realm):
+ check_realm('place')
+
+
+def test_discussions(check_realm):
+ check_realm('discussion')
+
+
+def test_users(check_realm):
+ check_realm(
+ 'user',
+ obj_kwargs={'username': 'Пользователь заголовок', 'profile_public': True},
+ views_hooks={'edit': lambda contents: True})
+
+
+def test_communities(check_realm):
+ check_realm('community')
+
+
+def test_versions(check_realm):
+ check_realm('version', obj_kwargs={
+ 'date': timezone.now()
+ })
+
+
+def test_peps(check_realm):
+ check_realm('pep', obj_kwargs={
+ 'num': 1,
+ 'status': PEP.Status.ACTIVE,
+ })
+
+
+def test_persons(check_realm):
+ check_realm('person', obj_kwargs={
+ 'name_en': 'Персона заголовок',
+ })
+
+
+def test_categories_feed(request_client, robot, init_category):
+ client = request_client(user=None)
+
+ category = init_category(title='someti')
+
+ article = Article(submitter=robot)
+ article.mark_published()
+ article.save()
+ article.add_to_category(category, user=robot)
+
+ content = client.get(f'/categories/{category.id}/feed/').content.decode()
+ assert f'
article_{article.id} ' in content
diff --git a/urls.py b/urls.py
deleted file mode 100644
index 5d71ef05..00000000
--- a/urls.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from siteprefs.toolbox import autodiscover_siteprefs
-from sitegate.toolbox import get_sitegate_urls
-from sitemessage.toolbox import get_sitemessage_urls
-from django.conf.urls import patterns, include, url
-from django.shortcuts import render
-from django.conf import settings
-from django.conf.urls.static import static
-from django.contrib import admin
-
-from apps.realms import bootstrap_realms # Здесь относительный импорт работать не будет.
-from apps.views import page_not_found, permission_denied, server_error
-
-autodiscover_siteprefs()
-
-
-urlpatterns = patterns('',
- url(r'^$', 'apps.views.index', name='index'),
- url(r'^search/site/$', render, {'template_name': 'static/search_site.html'}),
- url(r'^search/$', 'apps.views.search', name='search'),
- url(r'^login/$', 'apps.views.login', name='login'),
- url(r'^logout/$', 'django.contrib.auth.views.logout', {'next_page': '/'}, name='logout'),
- url(r'^promo/$', render, {'template_name': 'static/promo.html'}),
- url(r'^about/$', render, {'template_name': 'static/about.html'}),
- url(r'^sitemap/$', render, {'template_name': 'static/sitemap.html'}),
- url(r'^%s/' % settings.ADMIN_URL, include(admin.site.urls)),
-)
-
-urlpatterns += get_sitegate_urls() # Цепляем URLы от sitegate,
-urlpatterns += get_sitemessage_urls() # Цепляем URLы от sitemessage,
-
-bootstrap_realms(urlpatterns) # Инициализируем области
-
-
-# Используем собственные страницы ошибок.
-handler403 = permission_denied
-handler404 = page_not_found
-handler500 = server_error
-
-
-if settings.DEBUG:
- # Чтобы рабютала отладочная панель.
- import debug_toolbar
- urlpatterns += patterns('', url(r'^__debug__/', include(debug_toolbar.urls)),)
- # Чтобы статика раздавалась при runserver.
- urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 00000000..55722baf
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,812 @@
+version = 1
+revision = 3
+requires-python = "==3.12.*"
+
+[[package]]
+name = "asgiref"
+version = "3.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" },
+]
+
+[[package]]
+name = "awesome-slugify"
+version = "1.6.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "regex" },
+ { name = "unidecode" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/34/39/79ef4e640c3651b40de7812f5fcd04698abf14de4f57a81e12b6c753d168/awesome-slugify-1.6.5.tar.gz", hash = "sha256:bbdec3fa2187917473a2efad092b57f7125a55f841a7cf6a1773178d32ccfd71", size = 8405, upload-time = "2015-06-05T06:31:13.651Z" }
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" },
+]
+
+[[package]]
+name = "bleach"
+version = "6.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406, upload-time = "2024-10-29T18:30:38.186Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2025.7.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload-time = "2025-07-09T02:13:58.874Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload-time = "2025-07-09T02:13:57.007Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" },
+ { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" },
+ { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" },
+ { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" },
+ { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" },
+ { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" },
+ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
+]
+
+[[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 = "5.2.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asgiref" },
+ { name = "sqlparse" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9c/7e/034f0f9fb10c029a02daaf44d364d6bf2eced8c73f0d38c69da359d26b01/django-5.2.4.tar.gz", hash = "sha256:a1228c384f8fa13eebc015196db7b3e08722c5058d4758d20cb287503a540d8f", size = 10831909, upload-time = "2025-07-02T18:47:39.19Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/ae/706965237a672434c8b520e89a818e8b047af94e9beb342d0bee405c26c7/django-5.2.4-py3-none-any.whl", hash = "sha256:60c35bd96201b10c6e7a78121bd0da51084733efa303cc19ead021ab179cef5e", size = 8302187, upload-time = "2025-07-02T18:47:35.373Z" },
+]
+
+[[package]]
+name = "django-admirarchy"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/a9/e212518bbd27281b9ef14c8c0d67a7634d460c91455b601d2d7e68bacbbb/django-admirarchy-1.2.2.tar.gz", hash = "sha256:c58976f56f75e6a9f90e82d08bc05e9c9cc5743517c9ad3b772ce894b7e709f2", size = 16425, upload-time = "2021-12-18T03:56:20.238Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/d9/64723173442b19ad2ea9bdbf753346355ea82e084f55362cf1a0cad56787/django_admirarchy-1.2.2-py2.py3-none-any.whl", hash = "sha256:11fcc7772be754c098e1ac8a0beddda717e146f5048fe7aa3af7b7a0143298e7", size = 14334, upload-time = "2021-12-18T03:56:22.84Z" },
+]
+
+[[package]]
+name = "django-debug-toolbar"
+version = "5.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "sqlparse" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2a/9f/97ba2648f66fa208fc7f19d6895586d08bc5f0ab930a1f41032e60f31a41/django_debug_toolbar-5.2.0.tar.gz", hash = "sha256:9e7f0145e1a1b7d78fcc3b53798686170a5b472d9cf085d88121ff823e900821", size = 297901, upload-time = "2025-04-29T05:23:57.533Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/c2/ed3cb815002664349e9e50799b8c00ef15941f4cad797247cadbdeebab02/django_debug_toolbar-5.2.0-py3-none-any.whl", hash = "sha256:15627f4c2836a9099d795e271e38e8cf5204ccd79d5dbcd748f8a6c284dcd195", size = 262834, upload-time = "2025-04-29T05:23:55.472Z" },
+]
+
+[[package]]
+name = "django-etc"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/3b/c164f4edd0aa3f5026e15aaa982771c506c2d473bbee97bbf1b7e90aefc0/django-etc-1.4.0.tar.gz", hash = "sha256:fa7dbcdba5d0dd3b42eac54463fe24a9e1202a1492ecbf50ed0bc1555074af8f", size = 26070, upload-time = "2022-10-06T13:14:48.553Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/ad/dbfcd9aeace88a9b34b88aac5ad17418bb982343c0c72cb95491527af910/django_etc-1.4.0-py3-none-any.whl", hash = "sha256:61aee75a97ef868830260de86f2a69896992776c325624c46827c831101cbd9f", size = 21363, upload-time = "2022-10-06T13:14:52.251Z" },
+]
+
+[[package]]
+name = "django-robots"
+version = "6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/57/52bd04e1606bd9427286d53bf48c423e438dcc43754406de44e659d4edbc/django-robots-6.1.tar.gz", hash = "sha256:f86bcc3d16d7d7c2a4e37af6063cb4785f50ae16943f82248b48c9e7ac034f1d", size = 38764, upload-time = "2023-09-07T12:08:54.272Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7d/43/44f187df1c0edea8aaafb3db48415a4a94a52bd3f676abb40d9a6b74e247/django_robots-6.1-py3-none-any.whl", hash = "sha256:07e11a1bf3ddc08290123ec3c55abb45dbbffb9b38aea0a002e9b4a87bd9abcc", size = 59304, upload-time = "2023-09-07T12:08:05.15Z" },
+]
+
+[[package]]
+name = "django-simple-history"
+version = "3.10.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/16/2b/8d43eed1f9de32e890e0f5780e6d623a5a7e645e0e77a515edd427029f3c/django_simple_history-3.10.1.tar.gz", hash = "sha256:040f0c2286bed730312aa15f0acee9e7e6f839c4bcd721693251aa7ec5b65d95", size = 233649, upload-time = "2025-06-20T20:22:32.722Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/c3/d4ba265feb7b8bad3cf0730a3a8d82bb40d14551f8c53c88895ec06fa51c/django_simple_history-3.10.1-py3-none-any.whl", hash = "sha256:e12c27abfcd7e801a9d274d94542549ce8c617b0b384ae69afb161b56cd02ba4", size = 78611, upload-time = "2025-06-20T20:22:30.878Z" },
+]
+
+[[package]]
+name = "django-siteajax"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/c5/5c5592da441913e4b7d5cfa2042e96b907ebddb879dbb8308ef526c2e418/django-siteajax-1.0.0.tar.gz", hash = "sha256:d221e635c8c0cfd8508f7657b25a30f479eeb3a3a33daf646584815799d13ac9", size = 16024, upload-time = "2023-01-21T03:54:59.576Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/99/297938a0cc60b1d24f1c75196d440ac923774824c42eab97dc7cd28155d8/django_siteajax-1.0.0-py3-none-any.whl", hash = "sha256:828d9f5680d274c818768c43f3f90908499942c866e3a3887b301739afbc573c", size = 13500, upload-time = "2023-01-21T03:54:57.237Z" },
+]
+
+[[package]]
+name = "django-siteblocks"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/69/2c950e8c44675297af6230eeb21a19bd09e5e204151f11b433de6ad3f889/django-siteblocks-1.2.1.tar.gz", hash = "sha256:e6c7dd579a43cad0120513a7c12f0007050fd5abf1b2d7e844fc7fa63cda83da", size = 20022, upload-time = "2021-12-18T04:34:20.734Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/97/38/33952b5edf2b9164420ab67626a03d0ee1ead724eb62ca0be708f52a0168/django_siteblocks-1.2.1-py2.py3-none-any.whl", hash = "sha256:4bfb963affc3ca727f318f5ca9db4297732ca74086be4982f00772946c7e09fd", size = 20941, upload-time = "2021-12-18T04:34:23.25Z" },
+]
+
+[[package]]
+name = "django-sitecats"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django-etc" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/21/b3/730b8834211b77e92a9bb7455fcef480f99fcf77c75f8a7255641e396194/django-sitecats-1.2.2.tar.gz", hash = "sha256:7e722d4d5527a2d04a7fdffb7e3bc3e95cda6569c9f4e4a042a3b98807ab0c96", size = 37602, upload-time = "2021-12-18T05:00:44.592Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/9a/3f5f69666846f01168c8a309855ec0a1e6459bd1ca5587335eaaac27db14/django_sitecats-1.2.2-py2.py3-none-any.whl", hash = "sha256:af495258369cca8adba857d2cf47bec6d3e0d2af61e268ba57cdbd069e6f0f02", size = 36305, upload-time = "2021-12-18T05:00:46.882Z" },
+]
+
+[[package]]
+name = "django-siteflags"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django-etc" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4c/16/488c448fe219464a4269d6d4d8c06efc03ce15af8764321365fedead9606/django-siteflags-1.3.0.tar.gz", hash = "sha256:8ee5fe945daea5a8f6748c4473c435449117da2c3971bae11c243fe6c9119480", size = 17541, upload-time = "2022-01-28T12:16:24.509Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/aa/7b/59914e83d26e713a51950b7a3d90f682745161c4c338898a58615ccec495/django_siteflags-1.3.0-py2.py3-none-any.whl", hash = "sha256:fb18b6fe9981a3d802c78316e6b582abbccdd0bce241887615fb5ab255c6ec4a", size = 13008, upload-time = "2022-01-28T12:16:26.667Z" },
+]
+
+[[package]]
+name = "django-siteforms"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/07/04/682ff2849480f73af511a10733cde21be3112f2ab06c48330baf67ca5f9a/django-siteforms-1.2.0.tar.gz", hash = "sha256:763e7e3f21bd5ebb5430e4d08c4024ad5928f8cf81605c7138aaf17508b40936", size = 40092, upload-time = "2023-09-08T14:52:11.945Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/63/8a/3341bfa144b63e01da904fd0b42b01eeecf1051894be1fc227f2031ac808/django_siteforms-1.2.0-py3-none-any.whl", hash = "sha256:ea228b10d33573c42ae374f0632a5838bd8b84d66e2b643fd5f77c2f326eaa76", size = 42275, upload-time = "2023-09-08T14:52:10.195Z" },
+]
+
+[[package]]
+name = "django-sitegate"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django-etc" },
+ { name = "django-siteprefs" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/82/69/1f32be582e3881734fa6cf29217e270d8f680567f096cd13db3bda924a59/django-sitegate-1.3.3.tar.gz", hash = "sha256:4f1906465bec7fd718c543fd015339785a5d32d270ab66e44df70a199be33391", size = 52055, upload-time = "2022-11-27T06:01:25.71Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/a7/f4e163ae8007351fb12133e9ec187756a42dfdf61f65d6729869712b00b5/django_sitegate-1.3.3-py3-none-any.whl", hash = "sha256:321db13b23659cf2b6bc69a1d6de2821648e2afa86fd6c5ec48c64729595e5f5", size = 64301, upload-time = "2022-11-27T06:01:28.785Z" },
+]
+
+[[package]]
+name = "django-sitemessage"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django-etc" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/25/84/a2efefdd1a25ccec8bcb89b21c37e06a4d190b9efa2e26b521f0024f1ac9/django-sitemessage-1.4.0.tar.gz", hash = "sha256:fb8717e1c36d29cd6d82295190f2b35d06bc513f6ae67e29c376a26a8d4ca212", size = 53728, upload-time = "2023-03-18T13:00:21.882Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/14/6e0ebebadc53bd0017d648e20873aebd1145268ac83785dea25c3103e39f/django_sitemessage-1.4.0-py3-none-any.whl", hash = "sha256:aab4bf92daad6324261f868af5c65414e669319e7c3f6dacb7653ac8fff63ddd", size = 52757, upload-time = "2023-03-18T13:00:19.316Z" },
+]
+
+[[package]]
+name = "django-sitemetrics"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f4/30/1a2e2d0b2ee1db832908a0ede1a9f12794ab02bd7582b5b570d0e1d3d38a/django-sitemetrics-1.2.0.tar.gz", hash = "sha256:15a34246958ea008f1397a824de3e99ab08f1021ce48aa9dbe5ee0a7a93eed65", size = 22018, upload-time = "2020-05-29T10:06:01.479Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/65/1c/8b315b6c42678800d212d4fb4a880561585d8e0450a926d0dd569859e75a/django_sitemetrics-1.2.0-py2.py3-none-any.whl", hash = "sha256:c26e531420b7b5351cadd12ebdf939979376e7776f3c644220235c9c73d03780", size = 25568, upload-time = "2020-05-29T10:06:03.612Z" },
+]
+
+[[package]]
+name = "django-siteprefs"
+version = "1.2.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django-etc" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ea/92/55b9d9d809d340ff8f164005d24b78baaa6b3342074ad4149e5b11a30e0c/django-siteprefs-1.2.3.tar.gz", hash = "sha256:65ae6a1403ae75e33e6c4d63ebe0974f511346a1cf2be10efff7ed56b6a0e13e", size = 22590, upload-time = "2021-12-18T04:38:44.792Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/28/d5/09a323b01c8fcff7f1d068d5bc2b349d510276110276b67259fc3143b6e4/django_siteprefs-1.2.3-py2.py3-none-any.whl", hash = "sha256:2c83c9216c2b341dd526d80489e1049da2960c38af2d61758b5c567298eb1137", size = 22221, upload-time = "2021-12-18T04:38:47.326Z" },
+]
+
+[[package]]
+name = "django-sitetree"
+version = "1.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/43/c4c868b89dcefad8c967655ba3d6d56ed8c5b87d5f58538a3e15b8ffcb70/django-sitetree-1.18.0.tar.gz", hash = "sha256:11228c67f27a4243921b9473d4212a588714c0038698673073e564375640021e", size = 91707, upload-time = "2023-12-24T03:30:22.358Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/0c/bbbe6440cbaec7a9f93ab7b1c137d59c7d62e2aac0751bf57179113b2960/django_sitetree-1.18.0-py3-none-any.whl", hash = "sha256:b53f784493031ae2f8edb98f177c10d0f5defc58c59a0cc4f1dfaa25ad761427", size = 119199, upload-time = "2023-12-24T03:30:19.815Z" },
+]
+
+[[package]]
+name = "django-yaturbo"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/48/88/6c526bb6338cde6aa5787d570c1765ae3a2f66f888ded98c54f96e26aa6f/django-yaturbo-1.0.1.tar.gz", hash = "sha256:15d4f4e3e6da35d21c414f674fdfd488d85168c4bb898f27f96088724cb78bf2", size = 12512, upload-time = "2021-12-18T04:23:04.063Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/46/aa1c4166e434ad33e6b6275c6c4d43e4e5daae747334cb7d9d111be0200d/django_yaturbo-1.0.1-py2.py3-none-any.whl", hash = "sha256:58c09033c96622b6f3c4fc9cc85d46f0836572f09dca7389cdc43cfba6923fc2", size = 8064, upload-time = "2021-12-18T04:23:06.39Z" },
+]
+
+[[package]]
+name = "envbox"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/8a/907100708b896882e69baf6634010aafd7006ed235a184eccc1a39bbfed0/envbox-1.3.0.tar.gz", hash = "sha256:0b0f3bde0039dfed19b09f1064621bdf8be90bc22e9d617c67bedebf38185e34", size = 19857, upload-time = "2022-06-08T13:30:09.663Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ed/9c/b3084e2d78fc5ff2f5453ab73f231e5583751482415f2e67545ef8c85eef/envbox-1.3.0-py3-none-any.whl", hash = "sha256:6964a23bbdcf2c66bc43d9a6576d0ad242da298badac07f5ec351efd7056ae58", size = 11388, upload-time = "2022-06-08T13:30:12.496Z" },
+]
+
+[[package]]
+name = "feedparser"
+version = "6.0.11"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sgmllib3k" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ff/aa/7af346ebeb42a76bf108027fe7f3328bb4e57a3a96e53e21fd9ef9dd6dd0/feedparser-6.0.11.tar.gz", hash = "sha256:c9d0407b64c6f2a065d0ebb292c2b35c01050cc0dc33757461aaabdc4c4184d5", size = 286197, upload-time = "2023-12-10T16:03:20.854Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/d4/8c31aad9cc18f451c49f7f9cfb5799dadffc88177f7917bc90a66459b1d7/feedparser-6.0.11-py3-none-any.whl", hash = "sha256:0be7ee7b395572b19ebeb1d6aafb0028dee11169f1c934e0ed67d54992f4ad45", size = 81343, upload-time = "2023-12-10T16:03:19.484Z" },
+]
+
+[[package]]
+name = "freezegun"
+version = "1.5.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c7/75/0455fa5029507a2150da59db4f165fbc458ff8bb1c4f4d7e8037a14ad421/freezegun-1.5.2.tar.gz", hash = "sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181", size = 34855, upload-time = "2025-05-24T12:38:47.051Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/b2/68d4c9b6431121b6b6aa5e04a153cac41dcacc79600ed6e2e7c3382156f5/freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b", size = 18715, upload-time = "2025-05-24T12:38:45.274Z" },
+]
+
+[[package]]
+name = "icalendar-light"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e2/bf/54df35c6b157aeb13b2f0675495ec900363d2927ed03f46f144fb38e54a6/icalendar_light-1.0.0.tar.gz", hash = "sha256:2a40283b5735b70480023a64e48b060a7d990c5facb7c2b85c256717d4dabf83", size = 11491, upload-time = "2020-10-31T02:01:19.619Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6b/b6/286e5eb1d421e07aa8b7bb6276cd9d0e32a5abd522ec361ac529439d9783/icalendar_light-1.0.0-py2.py3-none-any.whl", hash = "sha256:3d7470d8609856b46f6b50bbf9162f0a29669473d837bbd100b5b7f9fb8a4ccc", size = 6812, upload-time = "2020-10-31T02:01:21.572Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
+]
+
+[[package]]
+name = "lxml"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c5/ed/60eb6fa2923602fba988d9ca7c5cdbd7cf25faa795162ed538b527a35411/lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72", size = 4096938, upload-time = "2025-06-26T16:28:19.373Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/89/c3/d01d735c298d7e0ddcedf6f028bf556577e5ab4f4da45175ecd909c79378/lxml-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78718d8454a6e928470d511bf8ac93f469283a45c354995f7d19e77292f26108", size = 8429515, upload-time = "2025-06-26T16:26:06.776Z" },
+ { url = "https://files.pythonhosted.org/packages/06/37/0e3eae3043d366b73da55a86274a590bae76dc45aa004b7042e6f97803b1/lxml-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:84ef591495ffd3f9dcabffd6391db7bb70d7230b5c35ef5148354a134f56f2be", size = 4601387, upload-time = "2025-06-26T16:26:09.511Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/28/e1a9a881e6d6e29dda13d633885d13acb0058f65e95da67841c8dd02b4a8/lxml-6.0.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2930aa001a3776c3e2601cb8e0a15d21b8270528d89cc308be4843ade546b9ab", size = 5228928, upload-time = "2025-06-26T16:26:12.337Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/55/2cb24ea48aa30c99f805921c1c7860c1f45c0e811e44ee4e6a155668de06/lxml-6.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:219e0431ea8006e15005767f0351e3f7f9143e793e58519dc97fe9e07fae5563", size = 4952289, upload-time = "2025-06-28T18:47:25.602Z" },
+ { url = "https://files.pythonhosted.org/packages/31/c0/b25d9528df296b9a3306ba21ff982fc5b698c45ab78b94d18c2d6ae71fd9/lxml-6.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bd5913b4972681ffc9718bc2d4c53cde39ef81415e1671ff93e9aa30b46595e7", size = 5111310, upload-time = "2025-06-28T18:47:28.136Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/af/681a8b3e4f668bea6e6514cbcb297beb6de2b641e70f09d3d78655f4f44c/lxml-6.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:390240baeb9f415a82eefc2e13285016f9c8b5ad71ec80574ae8fa9605093cd7", size = 5025457, upload-time = "2025-06-26T16:26:15.068Z" },
+ { url = "https://files.pythonhosted.org/packages/99/b6/3a7971aa05b7be7dfebc7ab57262ec527775c2c3c5b2f43675cac0458cad/lxml-6.0.0-cp312-cp312-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d6e200909a119626744dd81bae409fc44134389e03fbf1d68ed2a55a2fb10991", size = 5657016, upload-time = "2025-07-03T19:19:06.008Z" },
+ { url = "https://files.pythonhosted.org/packages/69/f8/693b1a10a891197143c0673fcce5b75fc69132afa81a36e4568c12c8faba/lxml-6.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca50bd612438258a91b5b3788c6621c1f05c8c478e7951899f492be42defc0da", size = 5257565, upload-time = "2025-06-26T16:26:17.906Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/96/e08ff98f2c6426c98c8964513c5dab8d6eb81dadcd0af6f0c538ada78d33/lxml-6.0.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:c24b8efd9c0f62bad0439283c2c795ef916c5a6b75f03c17799775c7ae3c0c9e", size = 4713390, upload-time = "2025-06-26T16:26:20.292Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/83/6184aba6cc94d7413959f6f8f54807dc318fdcd4985c347fe3ea6937f772/lxml-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afd27d8629ae94c5d863e32ab0e1d5590371d296b87dae0a751fb22bf3685741", size = 5066103, upload-time = "2025-06-26T16:26:22.765Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/01/8bf1f4035852d0ff2e36a4d9aacdbcc57e93a6cd35a54e05fa984cdf73ab/lxml-6.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:54c4855eabd9fc29707d30141be99e5cd1102e7d2258d2892314cf4c110726c3", size = 4791428, upload-time = "2025-06-26T16:26:26.461Z" },
+ { url = "https://files.pythonhosted.org/packages/29/31/c0267d03b16954a85ed6b065116b621d37f559553d9339c7dcc4943a76f1/lxml-6.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c907516d49f77f6cd8ead1322198bdfd902003c3c330c77a1c5f3cc32a0e4d16", size = 5678523, upload-time = "2025-07-03T19:19:09.837Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/f7/5495829a864bc5f8b0798d2b52a807c89966523140f3d6fa3a58ab6720ea/lxml-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36531f81c8214e293097cd2b7873f178997dae33d3667caaae8bdfb9666b76c0", size = 5281290, upload-time = "2025-06-26T16:26:29.406Z" },
+ { url = "https://files.pythonhosted.org/packages/79/56/6b8edb79d9ed294ccc4e881f4db1023af56ba451909b9ce79f2a2cd7c532/lxml-6.0.0-cp312-cp312-win32.whl", hash = "sha256:690b20e3388a7ec98e899fd54c924e50ba6693874aa65ef9cb53de7f7de9d64a", size = 3613495, upload-time = "2025-06-26T16:26:31.588Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1e/cc32034b40ad6af80b6fd9b66301fc0f180f300002e5c3eb5a6110a93317/lxml-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:310b719b695b3dd442cdfbbe64936b2f2e231bb91d998e99e6f0daf991a3eba3", size = 4014711, upload-time = "2025-06-26T16:26:33.723Z" },
+ { url = "https://files.pythonhosted.org/packages/55/10/dc8e5290ae4c94bdc1a4c55865be7e1f31dfd857a88b21cbba68b5fea61b/lxml-6.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:8cb26f51c82d77483cdcd2b4a53cda55bbee29b3c2f3ddeb47182a2a9064e4eb", size = 3674431, upload-time = "2025-06-26T16:26:35.959Z" },
+]
+
+[[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 = "pillow"
+version = "11.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" },
+ { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" },
+ { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" },
+ { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" },
+]
+
+[[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 = "psycopg"
+version = "3.2.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/27/4a/93a6ab570a8d1a4ad171a1f4256e205ce48d828781312c0bbaff36380ecb/psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700", size = 158122, upload-time = "2025-05-13T16:11:15.533Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/b0/a73c195a56eb6b92e937a5ca58521a5c3346fb233345adc80fd3e2f542e2/psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6", size = 202705, upload-time = "2025-05-13T16:06:26.584Z" },
+]
+
+[[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 = "pysocks"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
+]
+
+[[package]]
+name = "pytelegrambotapi"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/6b/b3eaedc0a004c7a04a4b9ca941d38d0f94be4e9847dd537830a47218f436/pyTelegramBotAPI-4.9.0.tar.gz", hash = "sha256:2751903c7a978bdf8e854851fab2a61af0c1564fef41a71bd43cc8261d0209b0", size = 219933, upload-time = "2023-01-02T15:04:00.19Z" }
+
+[[package]]
+name = "pytest"
+version = "8.4.1"
+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/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
+]
+
+[[package]]
+name = "pytest-djangoapp"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/31/82/a6b67ad7be8ca385eea568c8d1a2cef11e1d9885911ce700c9878573f80c/pytest_djangoapp-1.3.0.tar.gz", hash = "sha256:60f335f5447d72d3879109ceb3331b85a32271ae0c5d49990bf633c1575355b9", size = 14900, upload-time = "2025-06-06T12:56:23.968Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/25/b2/a63faeeab915e124547c10057c2e3d3d729a623df7b60fb421cf00d83184/pytest_djangoapp-1.3.0-py3-none-any.whl", hash = "sha256:fe0799138023052f7b2597a2f46cdd111813f4f979510027a60bc5ff7247ef77", size = 20609, upload-time = "2025-06-06T12:56:22.236Z" },
+]
+
+[[package]]
+name = "pytest-responsemock"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "responses" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a2/10/e9ad385e041aa755f3ec16b91b72a7485bc3f900b087408ae176a7022241/pytest-responsemock-1.1.1.tar.gz", hash = "sha256:71abcebaf61f8930bd92bd643bfc9bc2891299e4e819e21fec18c8434d5dc9af", size = 6322, upload-time = "2022-03-10T01:47:40.732Z" }
+
+[[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 = "pythonz"
+source = { editable = "." }
+dependencies = [
+ { name = "awesome-slugify" },
+ { name = "beautifulsoup4" },
+ { name = "bleach" },
+ { name = "django" },
+ { name = "django-admirarchy" },
+ { name = "django-etc" },
+ { name = "django-robots" },
+ { name = "django-simple-history" },
+ { name = "django-siteajax" },
+ { name = "django-siteblocks" },
+ { name = "django-sitecats" },
+ { name = "django-siteflags" },
+ { name = "django-siteforms" },
+ { name = "django-sitegate" },
+ { name = "django-sitemessage" },
+ { name = "django-sitemetrics" },
+ { name = "django-siteprefs" },
+ { name = "django-sitetree" },
+ { name = "django-yaturbo" },
+ { name = "envbox" },
+ { name = "feedparser" },
+ { name = "icalendar-light" },
+ { name = "lxml" },
+ { name = "pillow" },
+ { name = "psycopg" },
+ { name = "pytelegrambotapi" },
+ { name = "pytz" },
+ { name = "regex" },
+ { name = "requests", extra = ["socks"] },
+ { name = "sentry-sdk" },
+ { name = "twitter" },
+ { name = "uwsgi" },
+ { name = "uwsgiconf", extra = ["cli"] },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "django-debug-toolbar" },
+ { name = "freezegun" },
+ { name = "pytest" },
+ { name = "pytest-djangoapp" },
+ { name = "pytest-responsemock" },
+]
+runtime = [
+ { name = "django-debug-toolbar" },
+]
+tests = [
+ { name = "freezegun" },
+ { name = "pytest" },
+ { name = "pytest-djangoapp" },
+ { name = "pytest-responsemock" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "awesome-slugify", specifier = "~=1.6.5" },
+ { name = "beautifulsoup4", specifier = "~=4.13" },
+ { name = "bleach", specifier = "~=6.2.0" },
+ { name = "django", specifier = "~=5.2.4" },
+ { name = "django-admirarchy", specifier = "~=1.2.2" },
+ { name = "django-etc", specifier = "~=1.4.0" },
+ { name = "django-robots", specifier = "~=6.1" },
+ { name = "django-simple-history", specifier = "~=3.10.1" },
+ { name = "django-siteajax", specifier = "~=1.0.0" },
+ { name = "django-siteblocks", specifier = "~=1.2.1" },
+ { name = "django-sitecats", specifier = "~=1.2.2" },
+ { name = "django-siteflags", specifier = "~=1.3.0" },
+ { name = "django-siteforms", specifier = "~=1.2.0" },
+ { name = "django-sitegate", specifier = "~=1.3.3" },
+ { name = "django-sitemessage", specifier = "~=1.4.0" },
+ { name = "django-sitemetrics", specifier = "~=1.2.0" },
+ { name = "django-siteprefs", specifier = "~=1.2.3" },
+ { name = "django-sitetree", specifier = "~=1.18.0" },
+ { name = "django-yaturbo", specifier = "~=1.0.1" },
+ { name = "envbox", specifier = "~=1.3.0" },
+ { name = "feedparser", specifier = "~=6.0.11" },
+ { name = "icalendar-light", specifier = "~=1.0.0" },
+ { name = "lxml", specifier = "~=6.0.0" },
+ { name = "pillow", specifier = "~=11.3.0" },
+ { name = "psycopg", specifier = "~=3.2.9" },
+ { name = "pytelegrambotapi", specifier = "~=4.9.0" },
+ { name = "pytz", specifier = "~=2025.2" },
+ { name = "regex", specifier = "~=2024.11.6" },
+ { name = "requests", extras = ["socks"], specifier = "~=2.32.4" },
+ { name = "sentry-sdk", specifier = "~=2.32.0" },
+ { name = "twitter", specifier = "~=1.19.6" },
+ { name = "uwsgi", specifier = "~=2.0.30" },
+ { name = "uwsgiconf", extras = ["cli"], specifier = "~=2.0.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "django-debug-toolbar", specifier = "~=5.2.0" },
+ { name = "freezegun" },
+ { name = "pytest" },
+ { name = "pytest-djangoapp", specifier = ">=1.3.0" },
+ { name = "pytest-responsemock" },
+]
+linters = []
+runtime = [{ name = "django-debug-toolbar", specifier = "~=5.2.0" }]
+tests = [
+ { name = "freezegun" },
+ { name = "pytest" },
+ { name = "pytest-djangoapp", specifier = ">=1.3.0" },
+ { name = "pytest-responsemock" },
+]
+
+[[package]]
+name = "pytz"
+version = "2025.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
+ { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
+ { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
+]
+
+[[package]]
+name = "regex"
+version = "2024.11.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494, upload-time = "2024-11-06T20:12:31.635Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781, upload-time = "2024-11-06T20:10:07.07Z" },
+ { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455, upload-time = "2024-11-06T20:10:09.117Z" },
+ { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759, upload-time = "2024-11-06T20:10:11.155Z" },
+ { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976, upload-time = "2024-11-06T20:10:13.24Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077, upload-time = "2024-11-06T20:10:15.37Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160, upload-time = "2024-11-06T20:10:19.027Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896, upload-time = "2024-11-06T20:10:21.85Z" },
+ { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997, upload-time = "2024-11-06T20:10:24.329Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725, upload-time = "2024-11-06T20:10:28.067Z" },
+ { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481, upload-time = "2024-11-06T20:10:31.612Z" },
+ { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896, upload-time = "2024-11-06T20:10:34.054Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138, upload-time = "2024-11-06T20:10:36.142Z" },
+ { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692, upload-time = "2024-11-06T20:10:38.394Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135, upload-time = "2024-11-06T20:10:40.367Z" },
+ { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567, upload-time = "2024-11-06T20:10:43.467Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.32.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
+]
+
+[package.optional-dependencies]
+socks = [
+ { name = "pysocks" },
+]
+
+[[package]]
+name = "responses"
+version = "0.25.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/81/7e/2345ac3299bd62bd7163216702bbc88976c099cfceba5b889f2a457727a1/responses-0.25.7.tar.gz", hash = "sha256:8ebae11405d7a5df79ab6fd54277f6f2bc29b2d002d0dd2d5c632594d1ddcedb", size = 79203, upload-time = "2025-03-11T15:36:16.624Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/fc/1d20b64fa90e81e4fa0a34c9b0240a6cfb1326b7e06d18a5432a9917c316/responses-0.25.7-py3-none-any.whl", hash = "sha256:92ca17416c90fe6b35921f52179bff29332076bb32694c0df02dcac2c6bc043c", size = 34732, upload-time = "2025-03-11T15:36:14.589Z" },
+]
+
+[[package]]
+name = "sentry-sdk"
+version = "2.32.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/10/59/eb90c45cb836cf8bec973bba10230ddad1c55e2b2e9ffa9d7d7368948358/sentry_sdk-2.32.0.tar.gz", hash = "sha256:9016c75d9316b0f6921ac14c8cd4fb938f26002430ac5be9945ab280f78bec6b", size = 334932, upload-time = "2025-06-27T08:10:02.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/a1/fc4856bd02d2097324fb7ce05b3021fb850f864b83ca765f6e37e92ff8ca/sentry_sdk-2.32.0-py2.py3-none-any.whl", hash = "sha256:6cf51521b099562d7ce3606da928c473643abe99b00ce4cb5626ea735f4ec345", size = 356122, upload-time = "2025-06-27T08:10:01.424Z" },
+]
+
+[[package]]
+name = "sgmllib3k"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" }
+
+[[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 = "soupsieve"
+version = "2.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" },
+]
+
+[[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 = "twitter"
+version = "1.19.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/93/9a/8fbcb7f8a095ac085558ce7994aa0bd31eea327ff03c4b6d47a9fd713741/twitter-1.19.6.tar.gz", hash = "sha256:80ddd69ae2eeb88313feedeea31bf119fd6e79541ee5b37abb9c43d233194e10", size = 53089, upload-time = "2022-09-14T13:35:10.071Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/69/5e018f6699349b108be288ff7ec8a17f589ce4fb9b7ddc964fbf6f321861/twitter-1.19.6-py2.py3-none-any.whl", hash = "sha256:1d9a3e45f2c440f308a7116d3672b0d1981aba8ac41cb7f3ed270ed50693f0e0", size = 50277, upload-time = "2022-09-14T13:35:08.063Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.14.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
+]
+
+[[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 = "unidecode"
+version = "0.4.21"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0e/26/6a4295c494e381d56bba986893382b5dd5e82e2643fc72e4e49b6c99ce15/Unidecode-0.04.21.tar.gz", hash = "sha256:280a6ab88e1f2eb5af79edff450021a0d3f0448952847cd79677e55e58bad051", size = 205931, upload-time = "2017-06-28T11:56:50.781Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/a1/9d7f3138ee3d79a1ab865a2cb38200ca778d85121db19fe264c76c981184/Unidecode-0.04.21-py2.py3-none-any.whl", hash = "sha256:61f807220eda0203a774a09f84b4304a3f93b5944110cc132af29ddb81366883", size = 228285, upload-time = "2017-06-28T11:56:53.617Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
+]
+
+[[package]]
+name = "uwsgi"
+version = "2.0.30"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/f0/d794e9c7359f488b158e88c9e718c5600efdb74a0daf77331e5ffb6c87c4/uwsgi-2.0.30.tar.gz", hash = "sha256:c12aa652124f062ac216077da59f6d247bd7ef938234445881552e58afb1eb5f", size = 822560, upload-time = "2025-06-03T08:13:53.772Z" }
+
+[[package]]
+name = "uwsgiconf"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/0f/9f326057168196a0719673d3a6e8cfd3eaa00eb8050f17a47dea30670d06/uwsgiconf-2.0.0.tar.gz", hash = "sha256:817e282a2ecf8ef39d8a736b88be276a8b246750f63117f6b9d726f4b4cbdca0", size = 127040, upload-time = "2025-06-10T13:51:16.773Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/9a/95ebbbd8f0bfd9101278b45190247a3f67bbbef92a69be0c06f76e58ca7d/uwsgiconf-2.0.0-py3-none-any.whl", hash = "sha256:121949b68c02d9ad050c4b0c6e5f4db8619f8070a04c654660883f9db3299ead", size = 172989, upload-time = "2025-06-10T13:51:14.116Z" },
+]
+
+[package.optional-dependencies]
+cli = [
+ { name = "click" },
+]
+
+[[package]]
+name = "webencodings"
+version = "0.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
+]
diff --git a/wscaff.yml b/wscaff.yml
new file mode 100644
index 00000000..a130630a
--- /dev/null
+++ b/wscaff.yml
@@ -0,0 +1,8 @@
+
+remotes: 84.201.133.6
+
+project:
+ name: pythonz
+ repo: git@github.com:idlesign/pythonz.git
+ domain: pythonz.net
+ email: idlesign@yandex.ru
diff --git a/wsgi.py b/wsgi.py
deleted file mode 100644
index 5f889d4f..00000000
--- a/wsgi.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import os
-import sys
-
-from django.core.wsgi import get_wsgi_application
-
-# Для правильного импорта модулей добавим пару путей в список поиска:
-PROJECT_PATH = os.path.realpath(os.path.dirname(__file__))
-sys.path = [os.path.dirname(PROJECT_PATH), PROJECT_PATH] + sys.path
-
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings.dev')
-application = get_wsgi_application()