├── astconfman ├── utils │ ├── __init__.py │ ├── formatters.py │ └── validators.py ├── instance │ └── .gitignore ├── migrations │ ├── README │ ├── script.py.mako │ ├── versions │ │ ├── 1a9f196d43b2_.py │ │ ├── 563f582d07fa_.py │ │ ├── 2728b7328b78_.py │ │ ├── 3a15e901e08c_.py │ │ ├── d7c7f3be40a_.py │ │ ├── c7c5c7d112c_.py │ │ └── 2798bc43117a_.py │ ├── alembic.ini │ └── env.py ├── static │ ├── logo.png │ ├── favicon.ico │ └── lang │ │ ├── languages.png │ │ └── languages.min.css ├── cron_job.sh ├── translations │ ├── ru │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ └── messages.pot ├── babel.cfg ├── translate.sh ├── templates │ ├── contact_create.html │ ├── contact_import.html │ ├── security │ │ ├── _messages.html │ │ ├── _macros.html │ │ ├── _menu.html │ │ ├── login_user.html │ │ └── register_user.html │ ├── conference_schedule_list.html │ ├── conference_create.html │ ├── admin │ │ └── index.html │ ├── conference_edit.html │ ├── my_master.html │ ├── action_conference.html │ └── conference_details.html ├── run.py ├── forms.py ├── config.py ├── manage.py ├── app.py ├── asterisk.py ├── models.py └── views.py ├── .gitignore ├── astconfman.service ├── requirements.txt ├── asterisk_etc ├── confbridge.conf ├── manager.conf └── extensions.conf ├── README.md └── LICENSE.txt /astconfman/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /astconfman/utils/formatters.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /astconfman/instance/.gitignore: -------------------------------------------------------------------------------- 1 | config.py 2 | -------------------------------------------------------------------------------- /astconfman/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | astconfman/astconfman.db 3 | astconfman/astconfman.log 4 | -------------------------------------------------------------------------------- /astconfman/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litnimax/astconfman/HEAD/astconfman/static/logo.png -------------------------------------------------------------------------------- /astconfman/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litnimax/astconfman/HEAD/astconfman/static/favicon.ico -------------------------------------------------------------------------------- /astconfman/static/lang/languages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litnimax/astconfman/HEAD/astconfman/static/lang/languages.png -------------------------------------------------------------------------------- /astconfman/cron_job.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd `dirname $0` 4 | 5 | source env/bin/activate 6 | 7 | ./manage.py start_conf $1 8 | -------------------------------------------------------------------------------- /astconfman/translations/ru/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litnimax/astconfman/HEAD/astconfman/translations/ru/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /astconfman/babel.cfg: -------------------------------------------------------------------------------- 1 | [ignore: env/**] 2 | [python: **.py] 3 | [jinja2: **/templates/**.html] 4 | encoding = utf-8 5 | extensions=jinja2.ext.autoescape,jinja2.ext.with_ 6 | -------------------------------------------------------------------------------- /astconfman/translate.sh: -------------------------------------------------------------------------------- 1 | pybabel extract -F babel.cfg -k lazy_gettext -o translations/messages.pot . 2 | pybabel update -i translations/messages.pot -d translations 3 | pybabel compile -d translations/ 4 | -------------------------------------------------------------------------------- /astconfman/templates/contact_create.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/model/create.html" %} 2 | 3 | {% block tail %} 4 |
5 |

{{_('You can also')}} {{_('Import Contacts')}}

6 |
7 | {% endblock %} -------------------------------------------------------------------------------- /astconfman/templates/contact_import.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/master.html" %} 2 | {% import "admin/lib.html" as lib with context %} 3 | 4 | {% block body %} 5 |

{{_('Import Contacts from a CSV file (phone, name):')}}

6 | {{ lib.render_form(form, dir_url) }} 7 | {{ error }} 8 | {% endblock %} -------------------------------------------------------------------------------- /astconfman/templates/security/_messages.html: -------------------------------------------------------------------------------- 1 | {%- with messages = get_flashed_messages(with_categories=true) -%} 2 | {% if messages %} 3 | 8 | {% endif %} 9 | {%- endwith %} -------------------------------------------------------------------------------- /astconfman/templates/conference_schedule_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/model/list.html" %} 2 | 3 | {% block model_menu_bar %} 4 |
5 | 6 |
7 | {{ super() }} 8 | {% endblock %} -------------------------------------------------------------------------------- /astconfman/run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from gevent.wsgi import WSGIServer 4 | from app import app 5 | 6 | 7 | if __name__=='__main__': 8 | server = WSGIServer((app.config['LISTEN_ADDRESS'], 9 | app.config['LISTEN_PORT']), 10 | app) 11 | server.serve_forever() -------------------------------------------------------------------------------- /astconfman.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=AsteriskConferenceManager 3 | After=network.target 4 | 5 | [Service] 6 | WorkingDirectory=/etc/asterisk/astconfman/astconfman/ 7 | ExecStart=/etc/asterisk/astconfman/env/bin/python /etc/asterisk/astconfman/astconfman/run.py 8 | Restart=always 9 | 10 | [Install] 11 | WantedBy=multi-user.target 12 | Alias=astconfman.service 13 | -------------------------------------------------------------------------------- /astconfman/templates/conference_create.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/model/create.html" %} 2 | 3 | {% macro conference_participants_link() %} 4 |
5 |
6 |
7 | {%- trans -%} 8 | You can create participants in menu Participants. 9 | {%- endtrans -%} 10 |
11 |
12 | {% endmacro %} -------------------------------------------------------------------------------- /astconfman/templates/admin/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | 3 | {% block body %} 4 | {% if config['BRAND_LOGO'] %} 5 |
6 |
7 | 8 | 9 | 10 |
11 |
12 | {% endif %} 13 | 14 | {% endblock %} -------------------------------------------------------------------------------- /astconfman/templates/conference_edit.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/model/edit.html" %} 2 | {% import 'admin/actions.html' as actionlib with context %} 3 | 4 | {% macro conference_participants_link() %} 5 |
6 |
7 |
8 | {%- trans -%} 9 | You can manage participants in menu Participants. 10 | {%- endtrans -%} 11 |
12 |
13 | {% endmacro %} 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Babel 2 | Flask==0.12.4 3 | SQLAlchemy==1.2.0b3 4 | flask-admin==1.3.0 5 | Flask-BabelEx 6 | Flask-Bootstrap 7 | Flask-SQLAlchemy 8 | Flask-WTF==0.14.3 9 | Flask-Script 10 | Flask-Migrate==2.7.0 11 | Flask-Security 12 | Jinja2 13 | MarkupSafe 14 | WTForms 15 | blinker 16 | gevent==1.2.2 17 | transliterate 18 | itsdangerous 19 | python-crontab 20 | pytz 21 | speaklater 22 | asterisk-ami 23 | alembic==0.8.6 24 | Werkzeug==0.11.10 25 | email_validator==1.1.3 26 | -------------------------------------------------------------------------------- /asterisk_etc/confbridge.conf: -------------------------------------------------------------------------------- 1 | ; See https://wiki.asterisk.org/wiki/display/AST/ConfBridge#ConfBridge-ConferenceMenuConfigurationOptions 2 | [user_menu] 3 | type=menu 4 | 1=toggle_mute 5 | 2=dialplan_exec(confman-unmute-request,s,1) 6 | 3=admin_toggle_mute_participants 7 | 4=decrease_listening_volume 8 | 5=reset_listening_volume 9 | 6=increase_listening_volume 10 | 7=decrease_talking_volume 11 | 8=reset_talking_volume 12 | 9=increase_talking_volume 13 | 0=dialplan_exec(confman-invite-all,s,1) 14 | -------------------------------------------------------------------------------- /astconfman/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /asterisk_etc/manager.conf: -------------------------------------------------------------------------------- 1 | ; 2 | ; Asterisk Call Management support 3 | ; 4 | 5 | ; By default asterisk will listen on localhost only. 6 | [general] 7 | enabled = yes 8 | port = 5038 9 | bindaddr = 127.0.0.1 10 | 11 | [conf] 12 | secret = 7890ec8ff2955ec70a1b390b62f023da 13 | read = system,call,log,verbose,command,agent,user,config,command,dtmf,reporting,cdr,dialplan,originate,message 14 | writetimeout = 500 15 | ;On high load systems amount of messages may overhelm python parser 16 | ;to mitigate it use event filters 17 | eventfilter=ConfbridgeTalking 18 | -------------------------------------------------------------------------------- /astconfman/migrations/versions/1a9f196d43b2_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 1a9f196d43b2 4 | Revises: None 5 | Create Date: 2015-08-17 18:06:31.273894 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '1a9f196d43b2' 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | pass 20 | ### end Alembic commands ### 21 | 22 | 23 | def downgrade(): 24 | ### commands auto generated by Alembic - please adjust! ### 25 | pass 26 | ### end Alembic commands ### 27 | -------------------------------------------------------------------------------- /astconfman/migrations/versions/563f582d07fa_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 563f582d07fa 4 | Revises: 1a9f196d43b2 5 | Create Date: 2015-08-17 18:06:50.928876 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '563f582d07fa' 11 | down_revision = '1a9f196d43b2' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.add_column('participant', sa.Column('is_invited', sa.Boolean(), nullable=True)) 20 | ### end Alembic commands ### 21 | 22 | 23 | def downgrade(): 24 | ### commands auto generated by Alembic - please adjust! ### 25 | op.drop_column('participant', 'is_invited') 26 | ### end Alembic commands ### 27 | -------------------------------------------------------------------------------- /astconfman/templates/security/_macros.html: -------------------------------------------------------------------------------- 1 | {% macro render_field_with_errors(field) %} 2 | 3 |
4 | {{ field.label }} {{ field(class_='form-control', **kwargs)|safe }} 5 | {% if field.errors %} 6 | 11 | {% endif %} 12 |
13 | {% endmacro %} 14 | 15 | {% macro render_field(field) %} 16 |

{{ field(class_='form-control', **kwargs)|safe }}

17 | {% endmacro %} 18 | 19 | {% macro render_checkbox_field(field) -%} 20 |
21 |
22 | 25 |
26 |
27 | {%- endmacro %} -------------------------------------------------------------------------------- /astconfman/templates/security/_menu.html: -------------------------------------------------------------------------------- 1 | {% if security.registerable or security.recoverable or security.confirmable %} 2 |

Menu

3 | 15 | {% endif %} -------------------------------------------------------------------------------- /astconfman/migrations/versions/2728b7328b78_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 2728b7328b78 4 | Revises: d7c7f3be40a 5 | Create Date: 2015-10-20 13:44:12.129389 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2728b7328b78' 11 | down_revision = 'd7c7f3be40a' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.drop_table('conference_schedule') 20 | ### end Alembic commands ### 21 | 22 | 23 | def downgrade(): 24 | ### commands auto generated by Alembic - please adjust! ### 25 | op.create_table('conference_schedule', 26 | sa.Column('id', sa.INTEGER(), nullable=False), 27 | sa.Column('entry', sa.VARCHAR(length=256), nullable=True), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /astconfman/migrations/versions/3a15e901e08c_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 3a15e901e08c 4 | Revises: 2728b7328b78 5 | Create Date: 2015-10-20 13:44:28.574687 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '3a15e901e08c' 11 | down_revision = '2728b7328b78' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table('conference_schedule', 20 | sa.Column('id', sa.Integer(), nullable=False), 21 | sa.Column('entry', sa.String(length=256), nullable=True), 22 | sa.PrimaryKeyConstraint('id') 23 | ) 24 | ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_table('conference_schedule') 30 | ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /astconfman/migrations/versions/d7c7f3be40a_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: d7c7f3be40a 4 | Revises: 563f582d07fa 5 | Create Date: 2015-10-20 13:40:57.501602 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'd7c7f3be40a' 11 | down_revision = '563f582d07fa' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table('conference_schedule', 20 | sa.Column('id', sa.Integer(), nullable=False), 21 | sa.Column('entry', sa.String(length=256), nullable=True), 22 | sa.PrimaryKeyConstraint('id') 23 | ) 24 | ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_table('conference_schedule') 30 | ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /astconfman/migrations/versions/c7c5c7d112c_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: c7c5c7d112c 4 | Revises: 3a15e901e08c 5 | Create Date: 2015-10-20 15:33:37.545510 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'c7c5c7d112c' 11 | down_revision = '3a15e901e08c' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.add_column('conference_schedule', sa.Column('conference', sa.Integer(), nullable=True)) 20 | op.create_foreign_key(None, 'conference_schedule', 'conference', ['conference'], ['id']) 21 | ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | ### commands auto generated by Alembic - please adjust! ### 26 | op.drop_constraint(None, 'conference_schedule', type_='foreignkey') 27 | op.drop_column('conference_schedule', 'conference') 28 | ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /astconfman/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /astconfman/templates/security/login_user.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | {% from "security/_macros.html" import render_field, render_field_with_errors, render_checkbox_field %} 3 | {% include "security/_messages.html" %} 4 | {% block body %} 5 | {{ super() }} 6 |
7 |
8 |

Login

9 |
10 |
11 | {{ login_user_form.hidden_tag() }} 12 | {{ render_field_with_errors(login_user_form.email) }} 13 | {{ render_field_with_errors(login_user_form.password) }} 14 | {{ render_checkbox_field(login_user_form.remember) }} 15 | {{ render_field(login_user_form.next) }} 16 | {{ render_field(login_user_form.submit, class="btn btn-primary") }} 17 |
18 |
19 |
20 |
21 | {% endblock body %} 22 | 23 | -------------------------------------------------------------------------------- /astconfman/templates/security/register_user.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | {% from "security/_macros.html" import render_field_with_errors, render_field %} 3 | {% include "security/_messages.html" %} 4 | {% block body %} 5 | {{ super() }} 6 |
7 |
8 |

Register

9 |
10 |
11 | {{ register_user_form.hidden_tag() }} 12 | {{ render_field_with_errors(register_user_form.email) }} 13 | {{ render_field_with_errors(register_user_form.password) }} 14 | {% if register_user_form.password_confirm %} 15 | {{ render_field_with_errors(register_user_form.password_confirm) }} 16 | {% endif %} 17 | {{ render_field(register_user_form.submit, class="btn btn-primary") }} 18 |
19 |

Already signed up? Please log in.

20 |
21 |
22 |
23 | {% endblock body %} -------------------------------------------------------------------------------- /astconfman/utils/validators.py: -------------------------------------------------------------------------------- 1 | from flask_babelex import gettext 2 | from wtforms.validators import ValidationError 3 | from crontab import CronTab, CronItem 4 | from models import Participant 5 | 6 | 7 | 8 | def is_number(form, field): 9 | if field.data and not field.data.isdigit(): 10 | raise ValidationError(gettext('Must be a number!')) 11 | 12 | 13 | def is_participant_uniq(form, field): 14 | # 15 | p = Participant.query.filter_by(conference=form.data['conference'], 16 | phone=form.data['phone']).first() 17 | if p: 18 | raise ValidationError( 19 | gettext('Participant with phone number %(num)s already there.', 20 | num=form.data['phone'])) 21 | 22 | 23 | 24 | def is_crontab_valid(form, field): 25 | item = CronItem(field.data + ' /bin/echo # Just a test', cron=CronTab()) 26 | # May be I will refactor to this: 27 | #item = cron.new(command='/bin/echo', comment='Aaaaa') 28 | #item.hour.every(4) 29 | #item.minute.during(5,50).every(2) 30 | #item.day.on(4,5,6) 31 | #item.dow.on(1) 32 | #item.month.during(1,2) 33 | if not item.is_valid(): 34 | raise ValidationError(gettext('%(job)s is not a correct crontab entry.', 35 | job=field.data)) 36 | 37 | -------------------------------------------------------------------------------- /astconfman/forms.py: -------------------------------------------------------------------------------- 1 | # *-* encoding:utf-8 *-* 2 | 3 | from flask_wtf import Form 4 | from flask_admin.form import BaseForm as BaseAdminForm 5 | from flask_wtf.file import FileField, file_required, file_allowed 6 | from wtforms.validators import ValidationError 7 | from flask_babelex import lazy_gettext as _ 8 | 9 | 10 | class ContactImportForm(Form): 11 | filename = FileField(_('File'), validators=[ 12 | file_required(), 13 | file_allowed(['csv', 'CSV'])]) 14 | 15 | def validate_filename(form, field): 16 | data = field.data.readlines() 17 | linenum = 1 18 | for line in data: 19 | if not len(line.split(',')) == 2: 20 | msg = _('CSV file is broken, line %(linenum)s', 21 | linenum=linenum) 22 | raise ValidationError(msg) 23 | elif not line[0].isdigit(): 24 | raise ValidationError(_( 25 | 'The first column does not contain phone ' 26 | 'number, line %(linenum)s', linenum=linenum)) 27 | linenum += 1 28 | field.data.seek(0) 29 | 30 | 31 | class ConferenceForm(BaseAdminForm): 32 | 33 | def validate_is_public(self, field): 34 | profile = self.data.get('public_participant_profile') 35 | if not profile: 36 | raise ValidationError(_(u'You must select a Public Participant' 37 | ' Profile for a Public Conference.')) 38 | 39 | -------------------------------------------------------------------------------- /astconfman/templates/my_master.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block head_css %} 4 | {{ super() }} 5 | 6 | {% endblock %} 7 | 8 | {% block access_control %} 9 | 10 | 33 | {% endblock %} 34 | 35 | {% block tail %} 36 |
37 |
38 | 39 |
40 | {% endblock %} -------------------------------------------------------------------------------- /astconfman/templates/action_conference.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | {% if current_user.has_role('admin') %} 18 |
19 | {% else %} 20 | 21 | {% endif %} 22 |
23 | {% for i in ids %} 24 | 25 | {% endfor %} 26 | 27 |

Select a Conference

28 | {% for c in conferences %} 29 |
30 | 34 |
35 | {% endfor %} 36 | 37 |

Select a Profile

38 | {% for p in profiles %} 39 |
40 | 44 |
45 | {% endfor %} 46 | 47 | 48 | 49 | 50 | 51 |
-------------------------------------------------------------------------------- /astconfman/migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | 6 | # this is the Alembic Config object, which provides 7 | # access to the values within the .ini file in use. 8 | config = context.config 9 | 10 | # Interpret the config file for Python logging. 11 | # This line sets up loggers basically. 12 | fileConfig(config.config_file_name) 13 | 14 | # add your model's MetaData object here 15 | # for 'autogenerate' support 16 | # from myapp import mymodel 17 | # target_metadata = mymodel.Base.metadata 18 | from flask import current_app 19 | config.set_main_option('sqlalchemy.url', current_app.config.get('SQLALCHEMY_DATABASE_URI')) 20 | target_metadata = current_app.extensions['migrate'].db.metadata 21 | 22 | # other values from the config, defined by the needs of env.py, 23 | # can be acquired: 24 | # my_important_option = config.get_main_option("my_important_option") 25 | # ... etc. 26 | 27 | 28 | def run_migrations_offline(): 29 | """Run migrations in 'offline' mode. 30 | 31 | This configures the context with just a URL 32 | and not an Engine, though an Engine is acceptable 33 | here as well. By skipping the Engine creation 34 | we don't even need a DBAPI to be available. 35 | 36 | Calls to context.execute() here emit the given string to the 37 | script output. 38 | 39 | """ 40 | url = config.get_main_option("sqlalchemy.url") 41 | context.configure(url=url) 42 | 43 | with context.begin_transaction(): 44 | context.run_migrations() 45 | 46 | 47 | def run_migrations_online(): 48 | """Run migrations in 'online' mode. 49 | 50 | In this scenario we need to create an Engine 51 | and associate a connection with the context. 52 | 53 | """ 54 | engine = engine_from_config(config.get_section(config.config_ini_section), 55 | prefix='sqlalchemy.', 56 | poolclass=pool.NullPool) 57 | 58 | connection = engine.connect() 59 | context.configure(connection=connection, 60 | target_metadata=target_metadata, 61 | **current_app.extensions['migrate'].configure_args) 62 | 63 | try: 64 | with context.begin_transaction(): 65 | context.run_migrations() 66 | finally: 67 | connection.close() 68 | 69 | if context.is_offline_mode(): 70 | run_migrations_offline() 71 | else: 72 | run_migrations_online() 73 | 74 | -------------------------------------------------------------------------------- /astconfman/config.py: -------------------------------------------------------------------------------- 1 | # *-* encoding: utf-8 *-* 2 | import os 3 | from flask_babelex import lazy_gettext as _ 4 | 5 | # Default Language. Currenly only 'ru' and 'en' are supported. 6 | LANGUAGE = 'en' 7 | 8 | # Put here some random string 9 | SECRET_KEY = 'change_me_here_to_random_key' 10 | 11 | # BRAND_NAV - this defines the string on the right top navigation bar 12 | BRAND_NAV = u'Asterisk Conference Manager' 13 | # BRAND_FOOTER - put here your company info 14 | BRAND_FOOTER = _(u"""(C) 2015 Asterisk Guru | www.asteriskguru.ru | Professional Asterisk support & development services.""") 15 | # BRAND_LOGO - replace logo.png or change here url to your own logo 16 | BRAND_LOGO = 'static/logo.png' 17 | # URL to redirect when clicked on LOGO. Put here '#' if redirect is not required. 18 | BRAND_LOGO_URL = 'http://www.pbxware.ru/' 19 | 20 | # ASTERISK_IPADDR - IP Address of Asterisk server. All other requests will be denied. 21 | ASTERISK_IPADDR = '127.0.0.1' 22 | 23 | # LISTEN_ADDRESS - Interfaces to bind to. '0.0.0.0' for all interfaces. 24 | LISTEN_ADDRESS = '0.0.0.0' 25 | 26 | # LISTEN_PORT - Port to listen on. 27 | LISTEN_PORT = 5000 28 | 29 | # Always leave DEBUG=False in production. DEBUG=True is a security hole as it 30 | # allows the execution of arbitrary Python code. Be warned! 31 | DEBUG = False 32 | 33 | # SQLALCHEMY_ECHO - prints SQL statements. 34 | SQLALCHEMY_ECHO = False 35 | 36 | # See http://docs.sqlalchemy.org/en/rel_1_0/core/engines.html#database-urls 37 | DATABASE_FILE = os.path.join(os.path.dirname(__file__), 'astconfman.db') 38 | SQLALCHEMY_DATABASE_URI = 'sqlite:///' + DATABASE_FILE 39 | 40 | WTF_CSRF_ENABLED = True 41 | 42 | SECURITY_REGISTERABLE = False 43 | SECURITY_RECOVERABLE = False 44 | SECURITY_SEND_PASSWORD_CHANGE_EMAIL = False 45 | SECURITY_USER_IDENTITY_ATTRIBUTES = 'username' 46 | SECURITY_PASSWORD_HASH = 'sha512_crypt' 47 | SECURITY_PASSWORD_SALT = 'bla-bla-bla' 48 | AMI_PASSWORD='7890ec8ff2955ec70a1b390b62f023da' 49 | AMI_USER='conf' 50 | 51 | # Asterisk 52 | ASTERISK_SPOOL_DIR = '/var/spool/asterisk/outgoing/' 53 | ASTERISK_MONITOR_DIR = '/var/spool/asterisk/monitor/' 54 | ASTERISK_EXECUTABLE = '/usr/sbin/asterisk' 55 | ASTERISK_SSH_ENABLED = False 56 | ASTERISK_SSH_PORT = '22' 57 | ASTERISK_SSH_HOST = 'localhost' 58 | ASTERISK_SSH_USER = 'asterisk' 59 | ASTERISK_SSH_KEY = 'ssh-rsa AAAAB3NzaC1yc2EA...' # Put your key in instance config 60 | 61 | # You can remove any tab by adding it here. 62 | DISABLED_TABS = [] 63 | 64 | # Callout template. 65 | CALLOUT_TEMPLATE = """Channel: Local/%(number)s@confman-dialout 66 | Context: confman-bridge 67 | Extension: %(confnum)s 68 | Priority: 1 69 | MaxRetries: 0 70 | RetryTime: 15 71 | WaitTime: 300 72 | Set: participant_name=%(name)s 73 | Set: participant_number=%(number)s 74 | Set: conf_number=%(confnum)s 75 | """ -------------------------------------------------------------------------------- /astconfman/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from flask import Flask 4 | from flask_babelex import gettext 5 | from flask_migrate import MigrateCommand 6 | from flask_security import utils 7 | from flask_script import Manager 8 | from app import app, db, migrate, user_datastore 9 | from models import Contact, Conference, Participant 10 | from models import ParticipantProfile, ConferenceProfile 11 | 12 | manager = Manager(app) 13 | manager.add_command('db', MigrateCommand) 14 | 15 | 16 | @manager.command 17 | def create_schema(): 18 | db.create_all() 19 | 20 | 21 | @manager.command 22 | def create_admin_admin(): 23 | user_datastore.create_role(name='admin', description='System administrator') 24 | user_datastore.create_role(name='user', description='Conference user') 25 | admin = user_datastore.create_user(username='admin', 26 | password=utils.encrypt_password('admin')) 27 | user_datastore.add_role_to_user(admin, 'admin') 28 | db.session.commit() 29 | 30 | 31 | @manager.command 32 | def init(): 33 | db.drop_all() 34 | db.create_all() 35 | 36 | # Create roles 37 | user_datastore.create_role(name='admin', description='System administrator') 38 | user_datastore.create_role(name='user', description='Conference user') 39 | admin = user_datastore.create_user(username='admin', 40 | password=utils.encrypt_password('admin')) 41 | user = user_datastore.create_user(username='user', 42 | password=utils.encrypt_password('user')) 43 | user_datastore.add_role_to_user(admin, 'admin') 44 | user_datastore.add_role_to_user(user, 'user') 45 | 46 | contacts = [ 47 | ('1010', gettext('John Smith')), 48 | ('1020', gettext('Sam Brown')), 49 | ] 50 | for c in contacts: 51 | rec = Contact(phone=c[0], name=c[1], user=admin) 52 | db.session.add(rec) 53 | 54 | guest_user_profile = ParticipantProfile(name=gettext('Guest'), startmuted=True) 55 | db.session.add(guest_user_profile) 56 | marked_user_profile = ParticipantProfile(name=gettext('Marker'),marked=True) 57 | db.session.add(marked_user_profile) 58 | admin_user_profile = ParticipantProfile(name=gettext('Administrator'), admin=True) 59 | db.session.add(admin_user_profile) 60 | 61 | conf_profile = ConferenceProfile(name=gettext('Default')) 62 | db.session.add(conf_profile) 63 | 64 | conf = Conference(number=100, 65 | name=gettext('Test Conference'), 66 | conference_profile=conf_profile, 67 | public_participant_profile=guest_user_profile, 68 | is_public=True, 69 | user=admin, 70 | ) 71 | db.session.add(conf) 72 | 73 | p1 = Participant(conference=conf, profile=admin_user_profile, phone='1001', 74 | user=admin) 75 | p2 = Participant(conference=conf, profile=guest_user_profile, phone='1002', 76 | user=admin) 77 | p3 = Participant(conference=conf, profile=marked_user_profile, phone='1003', 78 | user=admin) 79 | db.session.add(p1) 80 | db.session.add(p2) 81 | db.session.add(p3) 82 | 83 | db.session.commit() 84 | 85 | 86 | @manager.command 87 | def start_conf(conf_num): 88 | conf = Conference.query.filter_by(number=conf_num).first() 89 | if conf: 90 | conf.invite_participants() 91 | 92 | 93 | if __name__ == '__main__': 94 | manager.run() 95 | -------------------------------------------------------------------------------- /astconfman/migrations/versions/2798bc43117a_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 2798bc43117a 4 | Revises: c7c5c7d112c 5 | Create Date: 2016-02-12 23:02:59.822880 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2798bc43117a' 11 | down_revision = 'c7c5c7d112c' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table('role', 20 | sa.Column('id', sa.Integer(), nullable=False), 21 | sa.Column('name', sa.String(length=80), nullable=True), 22 | sa.Column('description', sa.String(length=255), nullable=True), 23 | sa.PrimaryKeyConstraint('id'), 24 | sa.UniqueConstraint('name') 25 | ) 26 | op.create_table('user', 27 | sa.Column('id', sa.Integer(), nullable=False), 28 | sa.Column('username', sa.String(length=255), nullable=True), 29 | sa.Column('email', sa.String(length=255), nullable=True), 30 | sa.Column('password', sa.String(length=255), nullable=True), 31 | sa.Column('active', sa.Boolean(), nullable=True), 32 | sa.Column('confirmed_at', sa.DateTime(), nullable=True), 33 | sa.PrimaryKeyConstraint('id'), 34 | sa.UniqueConstraint('email'), 35 | sa.UniqueConstraint('username') 36 | ) 37 | op.create_table('roles_users', 38 | sa.Column('user_id', sa.Integer(), nullable=True), 39 | sa.Column('role_id', sa.Integer(), nullable=True), 40 | sa.ForeignKeyConstraint(['role_id'], ['role.id'], ), 41 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) 42 | ) 43 | op.add_column(u'conference', sa.Column('user_id', sa.Integer(), nullable=True)) 44 | op.create_foreign_key(None, 'conference', 'user', ['user_id'], ['id']) 45 | op.add_column(u'conference_profile', sa.Column('user_id', sa.Integer(), nullable=True)) 46 | op.create_foreign_key(None, 'conference_profile', 'user', ['user_id'], ['id']) 47 | op.add_column(u'conference_schedule', sa.Column('user_id', sa.Integer(), nullable=True)) 48 | op.create_foreign_key(None, 'conference_schedule', 'user', ['user_id'], ['id']) 49 | op.add_column(u'contact', sa.Column('user_id', sa.Integer(), nullable=True)) 50 | op.create_foreign_key(None, 'contact', 'user', ['user_id'], ['id']) 51 | op.add_column(u'participant', sa.Column('user_id', sa.Integer(), nullable=True)) 52 | op.create_foreign_key(None, 'participant', 'user', ['user_id'], ['id']) 53 | op.add_column(u'participant_profile', sa.Column('user_id', sa.Integer(), nullable=True)) 54 | op.create_foreign_key(None, 'participant_profile', 'user', ['user_id'], ['id']) 55 | ### end Alembic commands ### 56 | 57 | 58 | def downgrade(): 59 | ### commands auto generated by Alembic - please adjust! ### 60 | op.drop_constraint(None, 'participant_profile', type_='foreignkey') 61 | op.drop_column(u'participant_profile', 'user_id') 62 | op.drop_constraint(None, 'participant', type_='foreignkey') 63 | op.drop_column(u'participant', 'user_id') 64 | op.drop_constraint(None, 'contact', type_='foreignkey') 65 | op.drop_column(u'contact', 'user_id') 66 | op.drop_constraint(None, 'conference_schedule', type_='foreignkey') 67 | op.drop_column(u'conference_schedule', 'user_id') 68 | op.drop_constraint(None, 'conference_profile', type_='foreignkey') 69 | op.drop_column(u'conference_profile', 'user_id') 70 | op.drop_constraint(None, 'conference', type_='foreignkey') 71 | op.drop_column(u'conference', 'user_id') 72 | op.drop_table('roles_users') 73 | op.drop_table('user') 74 | op.drop_table('role') 75 | ### end Alembic commands ### 76 | -------------------------------------------------------------------------------- /astconfman/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import os 4 | import gevent 5 | from gevent.queue import Queue 6 | from urllib import urlencode 7 | from flask import Flask, send_from_directory, request, Response, session 8 | from flask import g, redirect, url_for 9 | from flask_admin import Admin, AdminIndexView 10 | from flask_babelex import Babel, gettext, lazy_gettext 11 | from flask_migrate import Migrate 12 | from flask_security import Security, SQLAlchemyUserDatastore, \ 13 | UserMixin, RoleMixin, login_required 14 | from flask_sqlalchemy import SQLAlchemy, models_committed 15 | 16 | 17 | app = Flask('AstConfMan', instance_relative_config=True) 18 | app.config.from_object('config') 19 | 20 | 21 | # For smooth language switcher 22 | def append_to_query(s, param, value): 23 | params = {} 24 | params[param] = value 25 | return '%s?%s' % (request.path, urlencode(params)) 26 | app.jinja_env.filters['append_to_query'] = append_to_query 27 | 28 | 29 | try: 30 | app.config.from_pyfile('config.py') 31 | except IOError: 32 | pass 33 | 34 | 35 | db = SQLAlchemy() 36 | db.init_app(app) 37 | 38 | migrate = Migrate(app, db) 39 | 40 | from flask_bootstrap import Bootstrap 41 | Bootstrap(app) 42 | 43 | babel = Babel(app) 44 | @babel.localeselector 45 | def get_locale(): 46 | if request.args.get('lang'): 47 | session['lang'] = request.args.get('lang') 48 | return session.get('lang', app.config.get('LANGUAGE')) 49 | 50 | 51 | # Define models 52 | roles_users = db.Table('roles_users', 53 | db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), 54 | db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) 55 | 56 | class Role(db.Model, RoleMixin): 57 | id = db.Column(db.Integer(), primary_key=True) 58 | name = db.Column(db.String(80), unique=True) 59 | description = db.Column(db.String(255)) 60 | 61 | def __str__(self): 62 | return self.name 63 | 64 | def __hash__(self): 65 | return hash(self.name) 66 | 67 | 68 | class User(db.Model, UserMixin): 69 | id = db.Column(db.Integer, primary_key=True) 70 | username = db.Column(db.String(255), unique=True) 71 | email = db.Column(db.String(255), unique=True) 72 | password = db.Column(db.String(255)) 73 | active = db.Column(db.Boolean()) 74 | confirmed_at = db.Column(db.DateTime()) 75 | roles = db.relationship('Role', secondary=roles_users, 76 | backref=db.backref('users', lazy='dynamic')) 77 | 78 | def __str__(self): 79 | return self.username 80 | 81 | 82 | # Setup Flask-Security 83 | user_datastore = SQLAlchemyUserDatastore(db, User, Role) 84 | security = Security(app, user_datastore) 85 | 86 | 87 | sse_subscriptions = [] 88 | 89 | class ServerSentEvent(object): 90 | def __init__(self, data): 91 | self.data = data 92 | self.event = None 93 | self.id = None 94 | self.desc_map = { 95 | self.data : "data", 96 | self.event : "event", 97 | self.id : "id" 98 | } 99 | 100 | def encode(self): 101 | if not self.data: 102 | return "" 103 | lines = ["%s: %s" % (v, k) 104 | for k, v in self.desc_map.iteritems() if k] 105 | 106 | return "%s\n\n" % "\n".join(lines) 107 | 108 | 109 | @app.route("/sse_debug") 110 | def sse_debug(): 111 | return "Currently %d subscriptions" % len(sse_subscriptions) 112 | 113 | 114 | def sse_notify(room, command, message=''): 115 | msg = {"room": room, "command": command, "message": message} 116 | for sub in sse_subscriptions[:]: 117 | sub.put(json.dumps(msg)) 118 | 119 | 120 | @app.route("/sse_publish") 121 | def sse_publish(): 122 | gevent.spawn(sse_notify, '1', 'unmute_request', 'max') 123 | return "OK" 124 | 125 | @app.route("/sse_subscribe") 126 | def subscribe(): 127 | def gen(): 128 | q = Queue() 129 | sse_subscriptions.append(q) 130 | try: 131 | while True: 132 | result = q.get() 133 | ev = ServerSentEvent(str(result)) 134 | yield ev.encode() 135 | except GeneratorExit: # Or maybe use flask signals 136 | sse_subscriptions.remove(q) 137 | 138 | return Response(gen(), mimetype="text/event-stream") 139 | 140 | 141 | 142 | @app.route('/favicon.ico') 143 | def favicon(): 144 | return send_from_directory(os.path.join( 145 | app.root_path, 'static'), 146 | 'favicon.ico', 147 | mimetype='image/vnd.microsoft.icon') 148 | 149 | 150 | from views import asterisk 151 | app.register_blueprint(asterisk, url_prefix='/asterisk') 152 | 153 | 154 | from models import Contact, Conference, Participant, ParticipantProfile 155 | from models import ConferenceProfile 156 | from views import ContactAdmin, ParticipantProfileAdmin, ParticipantAdmin 157 | from views import ConferenceProfileAdmin, ConferenceAdmin, RecordingAdmin 158 | -------------------------------------------------------------------------------- /asterisk_etc/extensions.conf: -------------------------------------------------------------------------------- 1 | [globals](+) 2 | ; Set this to your Flask WEB server setting LISTEN_ADDRESS and LISTEN_PORT 3 | CONFMAN_HOST=http://localhost:5000 4 | ; This are examples of trunks used in [confman-dialout] 5 | DIALOUT_TRUNK1=SIP/trunk 6 | DIALOUT_TRUNK2=SIP/trunk2 7 | 8 | 9 | [confman-dialout] 10 | ; Define routes here 11 | exten => _X.,1,Dial(${DIALOUT_TRUNK1}/${EXTEN},60) 12 | ;exten => _XXXXXXX.,1,Dial(${DIALOUT_TRUNK2}/${EXTEN},60) ;Example of prefix to other trunk 13 | ; Always leave here priority 2! 14 | exten => _X.,2,Set(ret=${CURL(${CONFMAN_HOST}/asterisk/dial_status/${conf_number}/${participant_number}/${DIALSTATUS})}) 15 | 16 | [confman-bridge] 17 | exten => _X.,1,Verbose(Bridging ${participant_name} ${participant_number}) 18 | same => n,Set(CALLERID(all)=${participant_name} <${participant_number}>) 19 | same => n,Set(ret=${CURL(${CONFMAN_HOST}/asterisk/enter_conference/${conf_number}/${participant_number})}) 20 | same => n,Answer 21 | same => n,ConfBridge(${EXTEN},,,user_menu) 22 | exten => h,1,Set(ret=${CURL(${CONFMAN_HOST}/asterisk/leave_conference/${conf_number}/${participant_number})}) 23 | 24 | [confman-unmute-request] 25 | exten => s,1,Set(ret=${CURL(${CONFMAN_HOST}/asterisk/unmute_request/${conf_number}/${participant_number})}) 26 | 27 | ; This is called from conference menu to invite participants 28 | [confman-invite-all] 29 | exten => s,1,Set(ret=${CURL(${CONFMAN_HOST}/asterisk/invite_all/${conf_number}/${participant_number})}) 30 | same => n,GotoIf($["${ret}" = "NOTALLOWED"]?100) 31 | same => n,GotoIf($["${ret}" = "OK"]?200) 32 | same => n,Playback(an-error-has-occurred) 33 | same => 100,Playback(access-denied) 34 | same => 200,Playback(pls-wait-connect-call) 35 | 36 | [rooms] 37 | include => confman-dialin 38 | exten => 1,1,NoOp(${INDIAL_TIMEOUT}, ${ASTCONFMAN_URL}) 39 | 40 | [confman-dialin] 41 | 42 | ; Short numbers for conferences 43 | exten => _X.,1,Set(conf_number=${EXTEN}) 44 | exten => _X.,n,Set(participant_number=${CALLERID(num)}) 45 | exten => _X.,n,Set(participant_name=${CALLERID(name)}) 46 | exten => _X.,n,Set(dialin=yes) 47 | exten => _X.,n,Goto(confman-dialin,s,check) 48 | 49 | ; Ask for Conference number 50 | exten => s,1,Verbose(ConfMan Dialin Enter) 51 | same => n,Set(participant_number=${CALLERID(num)}) 52 | same => n,Set(participant_name=${CALLERID(name)}) 53 | same => n(enter-conf_number),Read(conf_number,conf-getconfno) 54 | same => n,GotoIf($["${conf_number}" = ""]?enter-conf_number) 55 | 56 | ; Check conference 57 | same => n(check),Set(check=${CURL(${CONFMAN_HOST}/asterisk/checkconf/${conf_number}/${CALLERID(num)})}) 58 | same => n,Verbose(Conference ${ARG1} check: ${check}) 59 | same => n,GotoIf($["${check}" = "NOCONF"]?noconf) 60 | same => n,GotoIf($["${check}" = "NOTPUBLIC"]?notpublic) 61 | same => n,GotoIf($["${check}" = "NOTAUTH"]?notauth) 62 | same => n,GotoIf($["${check}" = "OK"]?enter:error) 63 | 64 | ; Now get CONFBRIDGE bridge profile 65 | same => n(enter),Set(bridge_profile=${CURL(${CONFMAN_HOST}/asterisk/confprofile/${conf_number})}) 66 | same => n,NoOp(BRIDGE CONF PROFILE: ${bridge_profile}) 67 | same => n,While($["${SET(optval=${SHIFT(bridge_profile)})}" != ""]) 68 | same => n,Set(opt=${CUT(optval,=,1)}) 69 | same => n,Set(val=${CUT(optval,=,2)}) 70 | same => n,Set(CONFBRIDGE(bridge,${opt})=${val}) 71 | same => n,EndWhile 72 | ; Now get CONFBRIDGE user profile 73 | same => n,Set(user_profile=${CURL(${CONFMAN_HOST}/asterisk/userprofile/${conf_number}/${participant_number})}) 74 | same => n,NoOp(BRIDGE USER PROFILE: ${user_profile}) 75 | same => n,While($["${SET(optval=${SHIFT(user_profile)})}" != ""]) 76 | same => n,Set(opt=${CUT(optval,=,1)}) 77 | same => n,Set(val=${CUT(optval,=,2)}) 78 | same => n,Set(CONFBRIDGE(user,${opt})=${val}) 79 | same => n,EndWhile 80 | ; Enter Conference 81 | same => n,Goto(confman-bridge,${conf_number},1) 82 | 83 | ;same => n,Set(ret=${CURL(${CONFMAN_HOST}/asterisk/enter_conference/${conf_number}/${participant_number})}) 84 | ;same => n,ConfBridge(${conf_number},,,) 85 | ;same => n,Hangup 86 | ;exten => h,1,Set(ret=${CURL(${CONFMAN_HOST}/asterisk/leave_conference/${conf_number}/${participant_number})}) 87 | 88 | ; NOCONF 89 | same => n(noconf),Playback(conf-invalid) 90 | same => n,Wait(0.5) 91 | same => n,GotoIf($["${dialin}" = "yes"]?hangup:enter-conf_number) 92 | 93 | ; NOTPUBLIC 94 | same => n(notpublic),Verbose(NOT A PUBLIC CONFERENCE ${conf_number} OR ${CALLERID(num)} NOT IN PARTICIPANTS) 95 | same => n,Playback(ss-noservice) 96 | same => n,GotoIf($["${dialin}" = "yes"]?hangup:enter-conf_number) 97 | 98 | ; ERROR 99 | same => n(error),Verbose(Server returned unknown values probably error) 100 | same => n,Playback(an-error-has-occurred) 101 | same => n,GotoIf($["${dialin}" = "yes"]?hangup:enter-conf_number) 102 | 103 | ; NOTAUTH 104 | same => n(notauth),Verbose(Asterisk server not authorized to call URLs - check config.py) 105 | same => n,Playback(access-denied) 106 | same => n,GotoIf($["${dialin}" = "yes"]?hangup:enter-conf_number) 107 | 108 | 109 | ; Finally 110 | same => n(hangup),Hangup 111 | 112 | -------------------------------------------------------------------------------- /astconfman/asterisk.py: -------------------------------------------------------------------------------- 1 | import commands 2 | import os 3 | import shutil 4 | import tempfile 5 | from flask_babelex import gettext 6 | from transliterate import translit 7 | from app import app 8 | 9 | config = app.config 10 | 11 | 12 | def _cli_command(cmd): 13 | shell_cmd = "%s -rx '%s'" % (config['ASTERISK_EXECUTABLE'], cmd) 14 | if config['ASTERISK_SSH_ENABLED']: 15 | shell_cmd = 'ssh -p%s %s@%s "%s"' % (config['ASTERISK_SSH_PORT'], 16 | config['ASTERISK_SSH_USER'], 17 | config['ASTERISK_SSH_HOST'], 18 | shell_cmd) 19 | status, output = commands.getstatusoutput(shell_cmd) 20 | if status != 0: 21 | raise Exception(output) 22 | return output 23 | 24 | 25 | 26 | def confbridge_list(): 27 | rooms = [] 28 | output = _cli_command('confbridge list') 29 | for line in output.split('\n')[2:]: # Skip 2 line headers 30 | line = line.split() 31 | if line[0].isdigit(): 32 | rooms.append(line) 33 | return rooms 34 | 35 | 36 | def confbridge_list_participants(confno): 37 | output = _cli_command('confbridge list %s' % confno) 38 | if 'No conference' in output: 39 | return [] 40 | participants = [] 41 | lines = output.split('\n') 42 | header = lines[0].split() 43 | for line in lines[2:]: 44 | line = line.split() 45 | channel = line[0] 46 | flags = '' 47 | callerid = '' 48 | if len(header) == 7 and header[6] == 'CallerID': 49 | # ['Channel', 'User', 'Profile', 'Bridge', 'Profile', 'Menu', 'CallerID'] 50 | if len(line) == 3: 51 | # User Profile and Bridge Profile are empty as it should be. 52 | callerid = line[2] 53 | elif len(header) == 8 and header[7] == 'Muted': 54 | # ['Channel', 'User', 'Profile', 'Bridge', 'Profile', 'Menu', 'CallerID', 'Muted'] 55 | if len(line) == 4: 56 | # User Profile and Bridge Profile are empty as it should be. 57 | callerid = line[2] 58 | flags = 'm' if line[3] == 'Yes' else '' 59 | elif len(header) == 8 and header[1] == 'Flags': 60 | # ['Channel', 'Flags', User', 'Profile', 'Bridge', 'Profile', 'Menu', 'CallerID'] 61 | if len(line) == 3: 62 | # No flags no default profiles 63 | callerid = line[2] 64 | elif len(line) == 4: 65 | # Flags are set default profiles not set 66 | flags = line[1] 67 | callerid = line[3] 68 | elif len(line) == 5: 69 | # Flags are not set and default profiles are set 70 | callerid = line[4] 71 | elif len(line) == 6: 72 | # Flags are set and default profiles also set 73 | flags = line[1] 74 | callerid = line[5] 75 | 76 | participants.append({ 77 | 'channel': channel, 78 | 'flags': flags, 79 | 'callerid': callerid, 80 | } 81 | ) 82 | return participants 83 | 84 | 85 | def originate(confnum, number, name='', bridge_options=[], user_options=[]): 86 | tempname = tempfile.mktemp() 87 | f = open(tempname, mode='w') 88 | f.write(config['CALLOUT_TEMPLATE'] % {'number': number, 89 | 'name': translit(name, 'ru', 90 | reversed=True), 91 | 'confnum': confnum}) 92 | f.write('\n') 93 | # Now iterate over profile options 94 | for option in user_options: 95 | o, v = option.split('=') 96 | f.write('Set: CONFBRIDGE(user,%s)=%s\n' % (o, v)) 97 | for option in bridge_options: 98 | o, v = option.split('=') 99 | f.write('Set: CONFBRIDGE(bridge,%s)=%s\n' % (o, v)) 100 | 101 | f.flush() 102 | f.close() 103 | if config['ASTERISK_SSH_ENABLED']: 104 | ssh_cmd_prefix = 'ssh -p%s %s@%s "%%s"' % (config['ASTERISK_SSH_PORT'], 105 | config['ASTERISK_SSH_USER'], 106 | config['ASTERISK_SSH_HOST']) 107 | scp_cmd_prefix = 'scp -P%s %%s %s@%s:%%s' % (config['ASTERISK_SSH_PORT'], 108 | config['ASTERISK_SSH_USER'], 109 | config['ASTERISK_SSH_HOST']) 110 | remote_tmp_file = commands.getoutput(ssh_cmd_prefix % 'mktemp') 111 | scp_tmp_file = commands.getoutput(scp_cmd_prefix % (tempname, 112 | remote_tmp_file)) 113 | commands.getoutput(ssh_cmd_prefix % 'mv %s %s' % (remote_tmp_file, 114 | config['ASTERISK_SPOOL_DIR'])) 115 | else: 116 | # Move it to Asterisk outgoing calls queue. 117 | try: 118 | shutil.move(tempname, os.path.join( 119 | config['ASTERISK_SPOOL_DIR'], 120 | '%s.%s' % (confnum, number))) 121 | raise OSError 122 | except OSError: 123 | # This happends that Asterisk immediately deleted call file 124 | pass 125 | 126 | 127 | def confbridge_get(confno): 128 | output = _cli_command('confbridge list') 129 | for line in output.split('\n')[2:]: # Skip 2 line headers 130 | line = line.split() 131 | if line[0].isdigit() and line[0] == confno: 132 | return { 133 | 'name': line[0], 134 | 'users': int(line[1]), 135 | 'marked': False if line[2] == '0' else True, 136 | 'locked': False if line[3] == 'unlocked' or line[3] == 'No' else True 137 | } 138 | # If no conference is running return empty dict 139 | return { 140 | 'name': confno, 141 | 'users': 0, 142 | 'marked': False, 143 | 'locked': False 144 | } 145 | 146 | 147 | def confbridge_get_user_count(confno): 148 | return confbridge_get(confno)['users'] 149 | 150 | 151 | def confbridge_is_locked(confno): 152 | return confbridge_get(confno)['locked'] 153 | 154 | 155 | def confbridge_kick(confno, channel): 156 | return _cli_command('confbridge kick %s %s' % (confno, channel)) 157 | 158 | 159 | def confbridge_kick_all(confno): 160 | return _cli_command('confbridge kick %s all' % confno) 161 | 162 | 163 | def confbridge_mute(confno, channel): 164 | return _cli_command('confbridge mute %s %s' % (confno, channel)) 165 | 166 | 167 | def confbridge_unmute(confno, channel): 168 | return _cli_command('confbridge unmute %s %s' % (confno, channel)) 169 | 170 | 171 | def confbridge_lock(confno): 172 | return _cli_command('confbridge lock %s' % confno) 173 | 174 | 175 | def confbridge_unlock(confno): 176 | return _cli_command('confbridge unlock %s' % confno) 177 | 178 | 179 | def confbridge_record_start(confno): 180 | return _cli_command('confbridge record start %s' % confno) 181 | 182 | 183 | def confbridge_record_stop(confno): 184 | return _cli_command('confbridge record stop %s' % confno) 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Asterisk ConfBridge Manager 2 | 3 | **MAINTAINER IS WANTED** If you would like to maintain this repo pls create a new ticket. 4 | 5 | This is a WEB based interface for managing Asterisk ConfBridge application. 6 | 7 | **Built on Asterisk ConfBridge, Flask, SSE, React.js** 8 | 9 | You can request a [new feature](https://github.com/litnimax/astconfman/issues/new) or see current requests and bugs [here](https://github.com/litnimax/astconfman/issues). 10 | 11 | ### How it works 12 | Flask is used as a WEB server. By default it uses SQLite3 database for storage but other datasources are also supported (see config.py). 13 | 14 | Conference participants are invited using Asterisk call out files. To track participant dial status local channel is used. No AMI/AGI/ARI is used. Everything is built around ```asterisk -rx 'confbridge <...>'``` CLI commands. Asterisk and Flask are supposed to be run on the same server but it's possible to implement remote asterisk command execution via SSH. The software is distributed as as on BSD license. Asterisk resellers can easily implement their own logo and footer and freely redistribute it to own customers (see BRAND_ options in config.py). 15 | 16 | ### Features 17 | 18 | * Private (only for configured participants) and public (guests can join) conferences. 19 | * Muted participant can indicate unmute request. 20 | * Contact management (addressbook) with import contacts feature. 21 | * Conference recording (always / ondemand, web access to recordings). 22 | * Support for dynamic ConfBridge [profiles](https://wiki.asterisk.org/wiki/display/AST/ConfBridge#ConfBridge-BridgeProfileConfigurationOptions) (any profile option can be set). 23 | * Invite participants from WEB or phone (on press DTMF digit). 24 | * Invite guests on demand by phone number. 25 | * Conference management: 26 | * Lock / unlock conference; 27 | * Kick one / all; 28 | * Mute / unmute one / all 29 | * Realtime conference events log (enter, leave, kicked, mute / unmute, dial status, etc) 30 | * Asterisk intergrators re-branding ready (change logo, banner, footer) 31 | 32 | ### Demo 33 | ![atsconf](https://user-images.githubusercontent.com/14130087/154665193-1a5e98c8-ea81-4689-b75e-b5e81528301c.png) 34 | 35 | Here is the demo with the folling scenatio: 36 | * Import contacts. 37 | * Add contacts to participants. 38 | * Invite all participants into conference. 39 | * Enter conference from phone. 40 | * Unmute request from phone. 41 | * Invite customer by his PSTN number. 42 | * Enter non-public conference. 43 | 44 | [![Demo](http://img.youtube.com/vi/R1EV4D8cFj8/0.jpg)](https://youtu.be/R1EV4D8cFj8 "Demo") 45 | 46 | ### Installation 47 | #### Requirements 48 | 49 | * Asterisk 11, 12 or 13. Only Asterisk 12/13 have confbridge list flags (muted, admin, marked) so Asterisk 11 is supported partially. 50 | * Python 2.7 51 | 52 | On Ubuntu: 53 | ``` 54 | sudo apt-get install python-pip python-virtualenv python-dev 55 | ``` 56 | 57 | In Debian 11 pip2 and virtualenv was deleted from repository, install them manually: 58 | 59 | ``` 60 | curl https://bootstrap.pypa.io/pip/2.7/get-pip.py --output get-pip.py 61 | sudo python2 get-pip.py 62 | sudo pip2 install virtualenv 63 | ``` 64 | 65 | Download the latest version: 66 | ``` 67 | wget https://github.com/litnimax/astconfman/archive/master.zip 68 | unzip master.zip 69 | mv astconfman-master astconfman 70 | ``` 71 | Or you can clone the repository with: 72 | ``` 73 | git clone https://github.com/litnimax/astconfman.git 74 | ``` 75 | Next steps: 76 | ``` 77 | cd astconfman 78 | virtualenv env 79 | source env/bin/activate 80 | pip2 install -r requirements.txt 81 | mv env/lib/python2.7/site-packages/asterisk/ env/lib/python2.7/site-packages/asterisk2/ 82 | ``` 83 | The above will download and install all runtime requirements. 84 | 85 | To enable AMI events put content of astconfman/asterisk_etc/manager.conf file into /etc/asterisk/manager.conf or change AMI credentials in 'config.py' manually. 86 | Don't forget to enable talker detection events in participant profile settings and reload Asterisk config. 87 | 88 | Now you should init database and run the server: 89 | ``` 90 | cd astconfman 91 | ./manage.py init 92 | ./run.py 93 | ``` 94 | Now visit http://localhost:5000/ in your browser. 95 | 96 | **Default user/password is admin/admin**. Don't forget to override it. 97 | 98 | ### Configuration 99 | #### WEB server configuration 100 | Go to *instance* folder and create there config.py file with your local settings. See [config.py](https://github.com/litnimax/astconfman/blob/master/astconfman/config.py) for possible options to override. 101 | Options in config.py file are self-descriptive. 102 | 103 | #### Asterisk configuration 104 | Asterisk must have CURL function compiled and loaded. Check it with 105 | ``` 106 | *CLI> core show function CURL 107 | ``` 108 | You must include files in astconfman/asterisk_etc folder from your Asterisk installation. 109 | 110 | Put 111 | ``` 112 | #include /path/to/astconfman/asterisk_etc/extensions.conf 113 | ``` 114 | to your /etc/asterisk/extensions.conf 115 | and 116 | ``` 117 | #include /path/to/astconfman/asterisk_etc/confbridge.conf 118 | ``` 119 | to your /etc/asterisk/confbridge.conf. 120 | 121 | Open extensions.conf with your text editor and set your settings in *globals* section. 122 | 123 | Open /etc/asterisk/asterisk.conf and be sure that 124 | ``` 125 | live_dangerously = no 126 | ``` 127 | 128 | 129 | ### Participant menu 130 | While in the conference participants can use the following DTMF options: 131 | 132 | * 1 - Toggle mute / unmute myself. 133 | * 2 - Unmute request. 134 | * 3 - Toggle mute all participants (admin profile only). 135 | * 4 - Decrease listening volume. 136 | * 5 - Reset listening volume. 137 | * 6 - Increase listening volume. 138 | * 7 - Decrease talking volume. 139 | * 8 - Reset talking volume. 140 | * 9 - Increase talking volume. 141 | * 0 - Invite all / not yet connected participants (admin profile only). 142 | 143 | ### Dialplan for calling external users 144 | ``` 145 | [confman-dialout] 146 | include => localph 147 | include => extph 148 | 149 | [localph] 150 | exten => _XXX,1,Dial(SIP/${EXTEN},60) 151 | exten => _ХXX,2,Set(ret=${CURL(${CONFMAN_HOST}/asterisk/dial_status/${conf_number}/${participant_number}/${DIALSTATUS})}) 152 | [extph] 153 | exten => _XXXX.,1,Dial(${DIALOUT_TRUNK1}/${EXTEN},60) 154 | exten => _XXXX.,2,Set(ret=${CURL(${CONFMAN_HOST}/asterisk/dial_status/${conf_number}/${participant_number}/${DIALSTATUS})}) 155 | ``` 156 | 157 | ### Frequent errors 158 | #### Asterisk monitor path not accessible 159 | ``` 160 | (env)max@linux:~/astconfman/astconfman$ ./manage.py init 161 | Traceback (most recent call last): 162 | File "./manage.py", line 7, in 163 | from app import app, db, migrate 164 | File "/home/max/astconfman/astconfman/app.py", line 60, in 165 | from views import asterisk 166 | File "/home/max/astconfman/astconfman/views.py", line 608, in 167 | menu_icon_value='glyphicon-hdd' 168 | File "/home/max/astconfman/env/local/lib/python2.7/site-packages/flask_admin/contrib/fileadmin.py", line 193, in __init__ 169 | raise IOError('FileAdmin path "%s" does not exist or is not accessible' % base_path) 170 | IOError: FileAdmin path "/var/spool/asterisk/monitor/" does not exist or is not accessible 171 | 172 | (env)max@linux:~/astconfman/astconfman$ ls -l /var/spool/ 173 | итого 16 174 | drwxr-x--- 9 asterisk asterisk 4096 сент. 1 22:20 asterisk 175 | drwxr-xr-x 5 root root 4096 сент. 1 22:09 cron 176 | lrwxrwxrwx 1 root root 7 сент. 1 22:05 mail -> ../mail 177 | drwxr-xr-x 2 root root 4096 апр. 11 2014 plymouth 178 | drwx------ 2 syslog adm 4096 дек. 4 2013 rsyslog 179 | (env)max@linux:~/astconfman/astconfman$ 180 | ``` 181 | To fix it add user running astwebconf to asterisk group. 182 | 183 | #### Conference makes multiple outgoing calls on dialout call 184 | Check if run.py are running with same user as Asterisk and what env directory has read-write access for Asterisk user. 185 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /astconfman/models.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname, join 2 | from datetime import datetime 3 | from flask_babelex import gettext, lazy_gettext 4 | from datetime import datetime 5 | from sqlalchemy.ext.hybrid import hybrid_property 6 | from flask_sqlalchemy import before_models_committed 7 | import asterisk 8 | from crontab import CronTab 9 | from app import app, db, sse_notify 10 | 11 | 12 | 13 | 14 | class Contact(db.Model): 15 | id = db.Column(db.Integer, primary_key=True) 16 | name = db.Column(db.Unicode(128), index=True) 17 | phone = db.Column(db.String(32)) 18 | user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 19 | user = db.relationship('User', backref='contacts') 20 | 21 | def __unicode__(self): 22 | if self.name: 23 | return '%s <%s>' % (self.name, self.phone) 24 | else: 25 | return self.phone 26 | 27 | 28 | class Conference(db.Model): 29 | """Conference is an event held in in a Room""" 30 | id = db.Column(db.Integer, primary_key=True) 31 | number = db.Column(db.String(16), unique=True) 32 | name = db.Column(db.Unicode(128)) 33 | is_public = db.Column(db.Boolean) 34 | conference_profile_id = db.Column(db.Integer, 35 | db.ForeignKey('conference_profile.id')) 36 | conference_profile = db.relationship('ConferenceProfile') 37 | public_participant_profile_id = db.Column( 38 | db.Integer, 39 | db.ForeignKey('participant_profile.id')) 40 | public_participant_profile = db.relationship('ParticipantProfile') 41 | user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 42 | user = db.relationship('User', backref='conferences') 43 | 44 | 45 | def __str__(self): 46 | return '%s <%s>' % (self.name, self.number) 47 | 48 | 49 | def _online_participant_count(self): 50 | return asterisk.confbridge_get_user_count(self.number) or 0 51 | online_participant_count = property(_online_participant_count) 52 | 53 | 54 | def _invited_participant_count(self): 55 | return Participant.query.filter_by(conference=self, is_invited=True).count() 56 | invited_participant_count = property(_invited_participant_count) 57 | 58 | def _participant_count(self): 59 | return len(self.participants) 60 | participant_count = property(_participant_count) 61 | 62 | 63 | def _is_locked(self): 64 | return asterisk.confbridge_is_locked(self.number) 65 | is_locked = property(_is_locked) 66 | 67 | 68 | def log(self, message): 69 | post = ConferenceLog(conference=self, message=message) 70 | db.session.add(post) 71 | db.session.commit() 72 | sse_notify(self.id, 'log_message', message) 73 | 74 | 75 | def invite_participants(self): 76 | online_participants = [ 77 | k['callerid'] for k in asterisk.confbridge_list_participants( 78 | self.number)] 79 | gen = (p for p in self.participants if p.is_invited and p.phone \ 80 | not in online_participants) 81 | for p in gen: 82 | asterisk.originate(self.number, p.phone, name=p.name, 83 | bridge_options=self.conference_profile.get_confbridge_options(), 84 | user_options=p.profile.get_confbridge_options() 85 | ) 86 | 87 | 88 | class ConferenceLog(db.Model): 89 | id = db.Column(db.Integer, primary_key=True) 90 | added = db.Column(db.DateTime, default=datetime.now) 91 | message = db.Column(db.Unicode(1024)) 92 | conference_id = db.Column(db.Integer, db.ForeignKey('conference.id')) 93 | conference = db.relationship('Conference', backref='logs') 94 | 95 | def __str__(self): 96 | return '%s: %s' % (self.added, self.message) 97 | 98 | 99 | class Participant(db.Model): 100 | id = db.Column(db.Integer, primary_key=True) 101 | phone = db.Column(db.String(32), index=True) 102 | name = db.Column(db.Unicode(128)) 103 | is_invited = db.Column(db.Boolean, default=True) 104 | conference_id = db.Column(db.Integer, db.ForeignKey('conference.id')) 105 | conference = db.relationship('Conference', 106 | backref=db.backref( 107 | 'participants',)) 108 | #cascade="delete,delete-orphan")) 109 | profile_id = db.Column(db.Integer, db.ForeignKey('participant_profile.id')) 110 | profile = db.relationship('ParticipantProfile') 111 | user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 112 | user = db.relationship('User', backref='participants') 113 | 114 | __table_args__ = (db.UniqueConstraint('conference_id', 'phone', 115 | name='uniq_phone'),) 116 | 117 | def __str__(self): 118 | if self.name: 119 | return '%s <%s>' % (self.name, self.phone) 120 | else: 121 | return self.phone 122 | 123 | 124 | class ConferenceProfile(db.Model): 125 | id = db.Column(db.Integer, primary_key=True) 126 | name = db.Column(db.Unicode(128)) 127 | max_members = db.Column(db.Integer, default=50) 128 | record_conference = db.Column(db.Boolean) 129 | internal_sample_rate = db.Column(db.String(8)) 130 | mixing_interval = db.Column(db.String(2), default='20') 131 | video_mode = db.Column(db.String(16)) 132 | user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 133 | user = db.relationship('User', backref='conference_profiles') 134 | 135 | def __str__(self): 136 | return self.name 137 | 138 | def get_confbridge_options(self): 139 | options = [] 140 | if self.max_members: 141 | options.append('max_members=%s' % self.max_members) 142 | if self.record_conference: 143 | options.append('record_conference=yes') 144 | if self.internal_sample_rate: 145 | options.append( 146 | 'internal_sample_rate=%s' % self.internal_sample_rate) 147 | if self.mixing_interval: 148 | options.append('mixing_interval=%s' % self.mixing_interval) 149 | if self.video_mode: 150 | options.append('video_mode=%s' % self.video_mode) 151 | 152 | return options 153 | 154 | 155 | class ParticipantProfile(db.Model): 156 | id = db.Column(db.Integer, primary_key=True) 157 | name = db.Column(db.Unicode(128)) 158 | admin = db.Column(db.Boolean, index=True) 159 | marked = db.Column(db.Boolean, index=True) 160 | startmuted = db.Column(db.Boolean) 161 | music_on_hold_when_empty = db.Column(db.Boolean) 162 | music_on_hold_class = db.Column(db.String(64), default='default') 163 | quiet = db.Column(db.Boolean) 164 | announce_user_count = db.Column(db.Boolean) 165 | announce_user_count_all = db.Column(db.String(4)) 166 | announce_only_user = db.Column(db.Boolean) 167 | announcement = db.Column(db.String(128)) 168 | wait_marked = db.Column(db.Boolean) 169 | end_marked = db.Column(db.Boolean) 170 | dsp_drop_silence = db.Column(db.Boolean) 171 | dsp_talking_threshold = db.Column(db.Integer, default=160) 172 | dsp_silence_threshold = db.Column(db.Integer, default=2500) 173 | talk_detection_events = db.Column(db.Boolean) 174 | denoise = db.Column(db.Boolean) 175 | jitterbuffer = db.Column(db.Boolean) 176 | pin = db.Column(db.String, index=True) 177 | announce_join_leave = db.Column(db.Boolean) 178 | dtmf_passthrough = db.Column(db.Boolean) 179 | user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 180 | user = db.relationship('User', backref='participant_profiles') 181 | 182 | def __str__(self): 183 | return self.name 184 | 185 | def get_confbridge_options(self): 186 | options = [] 187 | if self.admin: 188 | options.append('admin=yes') 189 | if self.marked: 190 | options.append('marked=yes') 191 | if self.startmuted: 192 | options.append('startmuted=yes') 193 | if self.music_on_hold_when_empty: 194 | options.append('music_on_hold_when_empty=yes') 195 | if self.music_on_hold_class: 196 | options.append('music_on_hold_class=%s' % self.music_on_hold_class) 197 | if self.quiet: 198 | options.append('quiet=yes') 199 | if self.announce_user_count: 200 | options.append('announce_user_count=yes') 201 | if self.announce_user_count_all: 202 | options.append( 203 | 'announce_user_count_all=%s' % self.announce_user_count_all) 204 | if self.announce_only_user: 205 | options.append('announce_only_user=yes') 206 | if self.announcement: 207 | options.append('announcement=%s' % self.announcement) 208 | if self.wait_marked: 209 | options.append('wait_marked=yes') 210 | if self.end_marked: 211 | options.append('end_marked=yes') 212 | if self.dsp_drop_silence: 213 | options.append('dsp_drop_silence=yes') 214 | if self.dsp_talking_threshold: 215 | options.append( 216 | 'dsp_talking_threshold=%s' % self.dsp_talking_threshold) 217 | if self.dsp_silence_threshold: 218 | options.append( 219 | 'dsp_silence_threshold=%s' % self.dsp_silence_threshold) 220 | if self.talk_detection_events: 221 | options.append('talk_detection_events=yes') 222 | if self.denoise: 223 | options.append('denoise=yes') 224 | if self.jitterbuffer: 225 | options.append('jitterbuffer=yes') 226 | if self.pin: 227 | options.append('pin=%s' % self.pin) 228 | if self.announce_join_leave: 229 | options.append('announce_join_leave=yes') 230 | if self.dtmf_passthrough: 231 | options.append('dtmf_passthrough=yes') 232 | 233 | return options 234 | 235 | 236 | class ConferenceSchedule(db.Model): 237 | """ 238 | This is a model to keep planned conferences in crontab format. 239 | """ 240 | id = db.Column(db.Integer, primary_key=True) 241 | conference_id = db.Column(db.Integer, db.ForeignKey('conference.id')) 242 | conference = db.relationship('Conference') 243 | entry = db.Column(db.String(256)) 244 | user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 245 | user = db.relationship('User', backref='schedules') 246 | # May be will refactor :-) 247 | #minute = db.Column(db.String(64)) 248 | #hour = db.Column(db.String(64)) 249 | #day_of_month = db.Column(db.String(64)) 250 | #month = db.Column(db.String(64)) 251 | #day_of_week = db.Column(db.String(64)) 252 | 253 | def __str__(self): 254 | return self.entry 255 | -------------------------------------------------------------------------------- /astconfman/templates/conference_details.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/details.html' %} 2 | 3 | {% block head %} 4 | {{ super() }} 5 | 6 | 11 | 12 | 13 | 14 | 72 | 73 | {% endblock %} 74 | 75 | {% block body %} 76 | {{ super() }} 77 | 78 | {% block details_search %}{% endblock %} 79 | {% block details_table %}{% endblock %} 80 | 81 |
82 | 83 |
84 |
85 |
86 |
87 |
88 | 92 | 115 |
116 |
117 | 118 |
119 |
120 | 121 |
122 |
123 | 124 | 223 | 224 |
225 | 226 |
227 |

{{_('Conference Log')}}

228 |
229 | 230 | {% for log in model.logs | sort(attribute='added', reverse=True) %} 231 | 232 | {% endfor %} 233 |
{{ log.message }}
234 |
235 |
236 | 237 |
238 | 239 |
240 | {% endblock body %} 241 | -------------------------------------------------------------------------------- /astconfman/static/lang/languages.min.css: -------------------------------------------------------------------------------- 1 | .lang-xs{background-position:0 -473px;min-width:14px;height:11px;min-height:11px;max-height:11px;background-repeat:no-repeat;display:inline-block;background-image:url(languages.png)}.lang-sm{background-position:0 -1172px;min-width:22px;height:16px;min-height:16px;max-height:16px;background-repeat:no-repeat;display:inline-block;background-image:url(languages.png)}.lang-lg{background-position:0 -2134px;min-width:30px;height:22px;min-height:22px;max-height:22px;background-repeat:no-repeat;display:inline-block;background-image:url(languages.png)}.lang-xs[lang=ar]{background-position:0 0}.lang-xs[lang=be]{background-position:0 -11px}.lang-xs[lang=bg]{background-position:0 -22px}.lang-xs[lang=cs]{background-position:0 -33px}.lang-xs[lang=da]{background-position:0 -44px}.lang-xs[lang=de]{background-position:0 -55px}.lang-xs[lang=el]{background-position:0 -66px}.lang-xs[lang=en]{background-position:0 -77px}.lang-xs[lang=es]{background-position:0 -88px}.lang-xs[lang=et]{background-position:0 -99px}.lang-xs[lang=fi]{background-position:0 -110px}.lang-xs[lang=fr]{background-position:0 -121px}.lang-xs[lang=ga]{background-position:0 -132px}.lang-xs[lang=hi]{background-position:0 -143px}.lang-xs[lang=hr]{background-position:0 -154px}.lang-xs[lang=hu]{background-position:0 -165px}.lang-xs[lang=in]{background-position:0 -176px}.lang-xs[lang=is]{background-position:0 -187px}.lang-xs[lang=it]{background-position:0 -198px}.lang-xs[lang=iw]{background-position:0 -209px}.lang-xs[lang=ja]{background-position:0 -220px}.lang-xs[lang=ko]{background-position:0 -231px}.lang-xs[lang=lt]{background-position:0 -242px}.lang-xs[lang=lv]{background-position:0 -253px}.lang-xs[lang=mk]{background-position:0 -264px}.lang-xs[lang=ms]{background-position:0 -275px}.lang-xs[lang=mt]{background-position:0 -286px}.lang-xs[lang=nl]{background-position:0 -297px}.lang-xs[lang=no]{background-position:0 -308px}.lang-xs[lang=pl]{background-position:0 -319px}.lang-xs[lang=pt]{background-position:0 -330px}.lang-xs[lang=ro]{background-position:0 -341px}.lang-xs[lang=ru]{background-position:0 -352px}.lang-xs[lang=sk]{background-position:0 -363px}.lang-xs[lang=sl]{background-position:0 -374px}.lang-xs[lang=sq]{background-position:0 -385px}.lang-xs[lang=sr]{background-position:0 -396px}.lang-xs[lang=sv]{background-position:0 -407px}.lang-xs[lang=th]{background-position:0 -418px}.lang-xs[lang=tr]{background-position:0 -429px}.lang-xs[lang=uk]{background-position:0 -440px}.lang-xs[lang=vi]{background-position:0 -451px}.lang-xs[lang=zh]{background-position:0 -462px}.lang-sm[lang=ar]{background-position:0 -484px}.lang-sm[lang=be]{background-position:0 -500px}.lang-sm[lang=bg]{background-position:0 -516px}.lang-sm[lang=cs]{background-position:0 -532px}.lang-sm[lang=da]{background-position:0 -548px}.lang-sm[lang=de]{background-position:0 -564px}.lang-sm[lang=el]{background-position:0 -580px}.lang-sm[lang=en]{background-position:0 -596px}.lang-sm[lang=es]{background-position:0 -612px}.lang-sm[lang=et]{background-position:0 -628px}.lang-sm[lang=fi]{background-position:0 -644px}.lang-sm[lang=fr]{background-position:0 -660px}.lang-sm[lang=ga]{background-position:0 -676px}.lang-sm[lang=hi]{background-position:0 -692px}.lang-sm[lang=hr]{background-position:0 -708px}.lang-sm[lang=hu]{background-position:0 -724px}.lang-sm[lang=in]{background-position:0 -740px}.lang-sm[lang=is]{background-position:0 -756px}.lang-sm[lang=it]{background-position:0 -772px}.lang-sm[lang=iw]{background-position:0 -788px}.lang-sm[lang=ja]{background-position:0 -804px}.lang-sm[lang=ko]{background-position:0 -820px}.lang-sm[lang=lt]{background-position:0 -836px}.lang-sm[lang=lv]{background-position:0 -852px}.lang-sm[lang=mk]{background-position:0 -868px}.lang-sm[lang=ms]{background-position:0 -884px}.lang-sm[lang=mt]{background-position:0 -900px}.lang-sm[lang=nl]{background-position:0 -916px}.lang-sm[lang=no]{background-position:0 -932px}.lang-sm[lang=pl]{background-position:0 -948px}.lang-sm[lang=pt]{background-position:0 -964px}.lang-sm[lang=ro]{background-position:0 -980px}.lang-sm[lang=ru]{background-position:0 -996px}.lang-sm[lang=sk]{background-position:0 -1012px}.lang-sm[lang=sl]{background-position:0 -1028px}.lang-sm[lang=sq]{background-position:0 -1044px}.lang-sm[lang=sr]{background-position:0 -1060px}.lang-sm[lang=sv]{background-position:0 -1076px}.lang-sm[lang=th]{background-position:0 -1092px}.lang-sm[lang=tr]{background-position:0 -1108px}.lang-sm[lang=uk]{background-position:0 -1124px}.lang-sm[lang=vi]{background-position:0 -1140px}.lang-sm[lang=zh]{background-position:0 -1156px}.lang-lg[lang=ar]{background-position:0 -1188px}.lang-lg[lang=be]{background-position:0 -1210px}.lang-lg[lang=bg]{background-position:0 -1232px}.lang-lg[lang=cs]{background-position:0 -1254px}.lang-lg[lang=da]{background-position:0 -1276px}.lang-lg[lang=de]{background-position:0 -1298px}.lang-lg[lang=el]{background-position:0 -1320px}.lang-lg[lang=en]{background-position:0 -1342px}.lang-lg[lang=es]{background-position:0 -1364px}.lang-lg[lang=et]{background-position:0 -1386px}.lang-lg[lang=fi]{background-position:0 -1408px}.lang-lg[lang=fr]{background-position:0 -1430px}.lang-lg[lang=ga]{background-position:0 -1452px}.lang-lg[lang=hi]{background-position:0 -1474px}.lang-lg[lang=hr]{background-position:0 -1496px}.lang-lg[lang=hu]{background-position:0 -1518px}.lang-lg[lang=in]{background-position:0 -1540px}.lang-lg[lang=is]{background-position:0 -1562px}.lang-lg[lang=it]{background-position:0 -1584px}.lang-lg[lang=iw]{background-position:0 -1606px}.lang-lg[lang=ja]{background-position:0 -1628px}.lang-lg[lang=ko]{background-position:0 -1650px}.lang-lg[lang=lt]{background-position:0 -1672px}.lang-lg[lang=lv]{background-position:0 -1694px}.lang-lg[lang=mk]{background-position:0 -1716px}.lang-lg[lang=ms]{background-position:0 -1738px}.lang-lg[lang=mt]{background-position:0 -1760px}.lang-lg[lang=nl]{background-position:0 -1782px}.lang-lg[lang=no]{background-position:0 -1804px}.lang-lg[lang=pl]{background-position:0 -1826px}.lang-lg[lang=pt]{background-position:0 -1848px}.lang-lg[lang=ro]{background-position:0 -1870px}.lang-lg[lang=ru]{background-position:0 -1892px}.lang-lg[lang=sk]{background-position:0 -1914px}.lang-lg[lang=sl]{background-position:0 -1936px}.lang-lg[lang=sq]{background-position:0 -1958px}.lang-lg[lang=sr]{background-position:0 -1980px}.lang-lg[lang=sv]{background-position:0 -2002px}.lang-lg[lang=th]{background-position:0 -2024px}.lang-lg[lang=tr]{background-position:0 -2046px}.lang-lg[lang=uk]{background-position:0 -2068px}.lang-lg[lang=vi]{background-position:0 -2090px}.lang-lg[lang=zh]{background-position:0 -2112px}.lang-lbl-en:after,.lang-lbl-full:after,.lang-lbl:after{content:"Unknown language"}.lang-lbl[lang=ar]:after{content:"\000627\000644\000639\000631\000628\00064A\000629"}.lang-lbl[lang=be]:after{content:"\000411\000435\00043B\000430\000440\000443\000441\00043A\000456"}.lang-lbl[lang=bg]:after{content:"\000411\00044A\00043B\000433\000430\000440\000441\00043A\000438"}.lang-lbl[lang=cs]:after{content:"\00010Ce\000161tina"}.lang-lbl[lang=da]:after{content:"Dansk"}.lang-lbl[lang=de]:after{content:"Deutsch"}.lang-lbl[lang=el]:after{content:"\000395\0003BB\0003BB\0003B7\0003BD\0003B9\0003BA\0003AC"}.lang-lbl[lang=en]:after{content:"English"}.lang-lbl[lang=es]:after{content:"Espa\0000F1ol"}.lang-lbl[lang=et]:after{content:"Eesti"}.lang-lbl[lang=fi]:after{content:"Suomi"}.lang-lbl[lang=fr]:after{content:"Fran\0000E7ais"}.lang-lbl[lang=ga]:after{content:"Gaeilge"}.lang-lbl[lang=hi]:after{content:"\000939\00093F\000902\000926\000940"}.lang-lbl[lang=hr]:after{content:"Hrvatski"}.lang-lbl[lang=hu]:after{content:"Magyar"}.lang-lbl[lang=in]:after{content:"Bahasa\000020indonesia"}.lang-lbl[lang=is]:after{content:"\0000CDslenska"}.lang-lbl[lang=it]:after{content:"Italiano"}.lang-lbl[lang=iw]:after{content:"\0005E2\0005D1\0005E8\0005D9\0005EA"}.lang-lbl[lang=ja]:after{content:"\0065E5\00672C\008A9E"}.lang-lbl[lang=ko]:after{content:"\00D55C\00AD6D\00C5B4"}.lang-lbl[lang=lt]:after{content:"Lietuvi\000173"}.lang-lbl[lang=lv]:after{content:"Latvie\000161u"}.lang-lbl[lang=mk]:after{content:"\00041C\000430\00043A\000435\000434\00043E\00043D\000441\00043A\000438"}.lang-lbl[lang=ms]:after{content:"Bahasa\000020melayu"}.lang-lbl[lang=mt]:after{content:"Malti"}.lang-lbl[lang=nl]:after{content:"Nederlands"}.lang-lbl[lang=no]:after{content:"Norsk"}.lang-lbl[lang=pl]:after{content:"Polski"}.lang-lbl[lang=pt]:after{content:"Portugu\0000EAs"}.lang-lbl[lang=ro]:after{content:"Rom\0000E2n\000103"}.lang-lbl[lang=ru]:after{content:"\000420\000443\000441\000441\00043A\000438\000439"}.lang-lbl[lang=sk]:after{content:"Sloven\00010Dina"}.lang-lbl[lang=sl]:after{content:"Sloven\000161\00010Dina"}.lang-lbl[lang=sq]:after{content:"Shqipe"}.lang-lbl[lang=sr]:after{content:"\000421\000440\00043F\000441\00043A\000438"}.lang-lbl[lang=sv]:after{content:"Svenska"}.lang-lbl[lang=th]:after{content:"\000E44\000E17\000E22"}.lang-lbl[lang=tr]:after{content:"T\0000FCrk\0000E7e"}.lang-lbl[lang=uk]:after{content:"\000423\00043A\000440\000430\000457\00043D\000441\00044C\00043A\000430"}.lang-lbl[lang=vi]:after{content:"Ti\001EBFng\000020vi\001EC7t"}.lang-lbl[lang=zh]:after{content:"\004E2D\006587"}.lang-lbl-en[lang=ar]:after{content:"Arabic"}.lang-lbl-en[lang=be]:after{content:"Belarusian"}.lang-lbl-en[lang=bg]:after{content:"Bulgarian"}.lang-lbl-en[lang=cs]:after{content:"Czech"}.lang-lbl-en[lang=da]:after{content:"Danish"}.lang-lbl-en[lang=de]:after{content:"German"}.lang-lbl-en[lang=el]:after{content:"Greek"}.lang-lbl-en[lang=en]:after{content:"English"}.lang-lbl-en[lang=es]:after{content:"Spanish"}.lang-lbl-en[lang=et]:after{content:"Estonian"}.lang-lbl-en[lang=fi]:after{content:"Finnish"}.lang-lbl-en[lang=fr]:after{content:"French"}.lang-lbl-en[lang=ga]:after{content:"Irish"}.lang-lbl-en[lang=hi]:after{content:"Hindi"}.lang-lbl-en[lang=hr]:after{content:"Croatian"}.lang-lbl-en[lang=hu]:after{content:"Hungarian"}.lang-lbl-en[lang=in]:after{content:"Indonesian"}.lang-lbl-en[lang=is]:after{content:"Icelandic"}.lang-lbl-en[lang=it]:after{content:"Italian"}.lang-lbl-en[lang=iw]:after{content:"Hebrew"}.lang-lbl-en[lang=ja]:after{content:"Japanese"}.lang-lbl-en[lang=ko]:after{content:"Korean"}.lang-lbl-en[lang=lt]:after{content:"Lithuanian"}.lang-lbl-en[lang=lv]:after{content:"Latvian"}.lang-lbl-en[lang=mk]:after{content:"Macedonian"}.lang-lbl-en[lang=ms]:after{content:"Malay"}.lang-lbl-en[lang=mt]:after{content:"Maltese"}.lang-lbl-en[lang=nl]:after{content:"Dutch"}.lang-lbl-en[lang=no]:after{content:"Norwegian"}.lang-lbl-en[lang=pl]:after{content:"Polish"}.lang-lbl-en[lang=pt]:after{content:"Portuguese"}.lang-lbl-en[lang=ro]:after{content:"Romanian"}.lang-lbl-en[lang=ru]:after{content:"Russian"}.lang-lbl-en[lang=sk]:after{content:"Slovak"}.lang-lbl-en[lang=sl]:after{content:"Slovenian"}.lang-lbl-en[lang=sq]:after{content:"Albanian"}.lang-lbl-en[lang=sr]:after{content:"Serbian"}.lang-lbl-en[lang=sv]:after{content:"Swedish"}.lang-lbl-en[lang=th]:after{content:"Thai"}.lang-lbl-en[lang=tr]:after{content:"Turkish"}.lang-lbl-en[lang=uk]:after{content:"Ukrainian"}.lang-lbl-en[lang=vi]:after{content:"Vietnamese"}.lang-lbl-en[lang=zh]:after{content:"Chinese"}.lang-lbl-full[lang=ar]:after{content:"\000627\000644\000639\000631\000628\00064A\000629\0000A0/\0000A0Arabic"}.lang-lbl-full[lang=be]:after{content:"\000411\000435\00043B\000430\000440\000443\000441\00043A\000456\0000A0/\0000A0Belarusian"}.lang-lbl-full[lang=bg]:after{content:"\000411\00044A\00043B\000433\000430\000440\000441\00043A\000438\0000A0/\0000A0Bulgarian"}.lang-lbl-full[lang=cs]:after{content:"\00010Ce\000161tina\0000A0/\0000A0Czech"}.lang-lbl-full[lang=da]:after{content:"Dansk\0000A0/\0000A0Danish"}.lang-lbl-full[lang=de]:after{content:"Deutsch\0000A0/\0000A0German"}.lang-lbl-full[lang=el]:after{content:"\000395\0003BB\0003BB\0003B7\0003BD\0003B9\0003BA\0003AC\0000A0/\0000A0Greek"}.lang-lbl-full[lang=en]:after{content:"English\0000A0/\0000A0English"}.lang-lbl-full[lang=es]:after{content:"Espa\0000F1ol\0000A0/\0000A0Spanish"}.lang-lbl-full[lang=et]:after{content:"Eesti\0000A0/\0000A0Estonian"}.lang-lbl-full[lang=fi]:after{content:"Suomi\0000A0/\0000A0Finnish"}.lang-lbl-full[lang=fr]:after{content:"Fran\0000E7ais\0000A0/\0000A0French"}.lang-lbl-full[lang=ga]:after{content:"Gaeilge\0000A0/\0000A0Irish"}.lang-lbl-full[lang=hi]:after{content:"\000939\00093F\000902\000926\000940\0000A0/\0000A0Hindi"}.lang-lbl-full[lang=hr]:after{content:"Hrvatski\0000A0/\0000A0Croatian"}.lang-lbl-full[lang=hu]:after{content:"Magyar\0000A0/\0000A0Hungarian"}.lang-lbl-full[lang=in]:after{content:"Bahasa\000020indonesia\0000A0/\0000A0Indonesian"}.lang-lbl-full[lang=is]:after{content:"\0000CDslenska\0000A0/\0000A0Icelandic"}.lang-lbl-full[lang=it]:after{content:"Italiano\0000A0/\0000A0Italian"}.lang-lbl-full[lang=iw]:after{content:"\0005E2\0005D1\0005E8\0005D9\0005EA\0000A0/\0000A0Hebrew"}.lang-lbl-full[lang=ja]:after{content:"\0065E5\00672C\008A9E\0000A0/\0000A0Japanese"}.lang-lbl-full[lang=ko]:after{content:"\00D55C\00AD6D\00C5B4\0000A0/\0000A0Korean"}.lang-lbl-full[lang=lt]:after{content:"Lietuvi\000173\0000A0/\0000A0Lithuanian"}.lang-lbl-full[lang=lv]:after{content:"Latvie\000161u\0000A0/\0000A0Latvian"}.lang-lbl-full[lang=mk]:after{content:"\00041C\000430\00043A\000435\000434\00043E\00043D\000441\00043A\000438\0000A0/\0000A0Macedonian"}.lang-lbl-full[lang=ms]:after{content:"Bahasa\000020melayu\0000A0/\0000A0Malay"}.lang-lbl-full[lang=mt]:after{content:"Malti\0000A0/\0000A0Maltese"}.lang-lbl-full[lang=nl]:after{content:"Nederlands\0000A0/\0000A0Dutch"}.lang-lbl-full[lang=no]:after{content:"Norsk\0000A0/\0000A0Norwegian"}.lang-lbl-full[lang=pl]:after{content:"Polski\0000A0/\0000A0Polish"}.lang-lbl-full[lang=pt]:after{content:"Portugu\0000EAs\0000A0/\0000A0Portuguese"}.lang-lbl-full[lang=ro]:after{content:"Rom\0000E2n\000103\0000A0/\0000A0Romanian"}.lang-lbl-full[lang=ru]:after{content:"\000420\000443\000441\000441\00043A\000438\000439\0000A0/\0000A0Russian"}.lang-lbl-full[lang=sk]:after{content:"Sloven\00010Dina\0000A0/\0000A0Slovak"}.lang-lbl-full[lang=sl]:after{content:"Sloven\000161\00010Dina\0000A0/\0000A0Slovenian"}.lang-lbl-full[lang=sq]:after{content:"Shqipe\0000A0/\0000A0Albanian"}.lang-lbl-full[lang=sr]:after{content:"\000421\000440\00043F\000441\00043A\000438\0000A0/\0000A0Serbian"}.lang-lbl-full[lang=sv]:after{content:"Svenska\0000A0/\0000A0Swedish"}.lang-lbl-full[lang=th]:after{content:"\000E44\000E17\000E22\0000A0/\0000A0Thai"}.lang-lbl-full[lang=tr]:after{content:"T\0000FCrk\0000E7e\0000A0/\0000A0Turkish"}.lang-lbl-full[lang=uk]:after{content:"\000423\00043A\000440\000430\000457\00043D\000441\00044C\00043A\000430\0000A0/\0000A0Ukrainian"}.lang-lbl-full[lang=vi]:after{content:"Ti\001EBFng\000020vi\001EC7t\0000A0/\0000A0Vietnamese"}.lang-lbl-full[lang=zh]:after{content:"\004E2D\006587\0000A0/\0000A0Chinese"}.lang-lg:before,.lang-sm:before,.lang-xs:before{content:'\0000A0'}.lang-xs.lang-lbl,.lang-xs.lang-lbl-en,.lang-xs.lang-lbl-full{padding-left:16px}.lang-sm.lang-lbl,.lang-sm.lang-lbl-en,.lang-sm.lang-lbl-full{padding-left:24px}.lang-lg.lang-lbl,.lang-lg.lang-lbl-en,.lang-lg.lang-lbl-full{padding-left:32px}.lang-lg.lang-lbl-en:before,.lang-lg.lang-lbl-full:before,.lang-lg.lang-lbl:before,.lang-sm.lang-lbl-en:before,.lang-sm.lang-lbl-full:before,.lang-sm.lang-lbl:before,.lang-xs.lang-lbl-en:before,.lang-xs.lang-lbl-full:before,.lang-xs.lang-lbl:before{content:''}.lang-lg,.lang-lg:after{top:0;position:relative}.lang-sm{top:1px;position:relative}.lang-sm:after{top:-1px;position:relative}.lang-xs{top:4px;position:relative}.lang-xs:after{top:-4px;position:relative}.lead>.lang-lg{top:2px}.lead>.lang-lg:after{top:-2px}.lead>.lang-sm{top:6px}.lead>.lang-sm:after{top:-6px}.lead>.lang-xs{top:8px}.lead>.lang-xs:after{top:-8px}small>.lang-sm{top:-1px}small>.lang-sm:after{top:1px}small>.lang-xs{top:2px}small>.lang-xs:after{top:-2px}h1>.lang-lg{top:9px}h1>.lang-lg:after{top:-9px}h1>.lang-sm{top:12px}h1>.lang-sm:after{top:-12px}h1>.lang-xs{top:14px}h1>.lang-xs:after{top:-14px}h2>.lang-lg{top:5px}h2>.lang-lg:after{top:-5px}h2>.lang-sm{top:8px}h2>.lang-sm:after{top:-8px}h2>.lang-xs{top:10px}h2>.lang-xs:after{top:-10px}h3>.lang-lg{top:1px}h3>.lang-lg:after{top:-1px}h3>.lang-sm{top:5px}h3>.lang-sm:after{top:-5px}h3>.lang-xs{top:8px}h3>.lang-xs:after{top:-8px}h4>.lang-lg{top:-1px}h4>.lang-lg:after,h4>.lang-sm{top:1px}h4>.lang-sm:after{top:-1px}h4>.lang-xs{top:4px}h4>.lang-xs:after{top:-4px}h5>.lang-sm,h5>.lang-sm:after{top:0}h5>.lang-xs{top:2px}h5>.lang-xs:after{top:-2px}h6>.lang-sm,h6>.lang-sm:after{top:0}h6>.lang-xs{top:1px}h6>.lang-xs:after{top:-1px}.btn>.lang-sm{top:2px}.btn>.lang-sm:after{top:-2px}.btn>.lang-xs{top:4px}.btn>.lang-xs:after{top:-4px}.btn.btn-xs>.lang-sm,.btn.btn-xs>.lang-sm:after{top:0}.btn.btn-xs>.lang-xs{top:3px}.btn.btn-xs>.lang-xs:after{top:-3px}.btn.btn-sm>.lang-sm,.btn.btn-sm>.lang-sm:after{top:0}.btn.btn-sm>.lang-xs{top:3px}.btn.btn-sm>.lang-xs:after{top:-3px}.btn.btn-lg>.lang-lg{top:1px}.btn.btn-lg>.lang-lg:after{top:-1px}.btn.btn-lg>.lang-sm{top:3px}.btn.btn-lg>.lang-sm:after{top:-3px}.btn.btn-lg>.lang-xs{top:6px}.btn.btn-lg>.lang-xs:after{top:-6px} -------------------------------------------------------------------------------- /astconfman/translations/messages.pot: -------------------------------------------------------------------------------- 1 | # Translations template for PROJECT. 2 | # Copyright (C) 2022 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2022. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PROJECT VERSION\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2022-02-18 09:51+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=utf-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Generated-By: Babel 2.9.1\n" 19 | 20 | #: config.py:14 21 | msgid "" 22 | "(C) 2015 Asterisk Guru | www.asteriskguru.ru | Professional " 24 | "Asterisk support & development services." 25 | msgstr "" 26 | 27 | #: forms.py:11 28 | msgid "File" 29 | msgstr "" 30 | 31 | #: forms.py:20 32 | #, python-format 33 | msgid "CSV file is broken, line %(linenum)s" 34 | msgstr "" 35 | 36 | #: forms.py:24 37 | #, python-format 38 | msgid "The first column does not contain phone number, line %(linenum)s" 39 | msgstr "" 40 | 41 | #: forms.py:36 42 | msgid "You must select a Public Participant Profile for a Public Conference." 43 | msgstr "" 44 | 45 | #: manage.py:47 46 | msgid "John Smith" 47 | msgstr "" 48 | 49 | #: manage.py:48 50 | msgid "Sam Brown" 51 | msgstr "" 52 | 53 | #: manage.py:54 54 | msgid "Guest" 55 | msgstr "" 56 | 57 | #: manage.py:56 58 | msgid "Marker" 59 | msgstr "" 60 | 61 | #: manage.py:58 62 | msgid "Administrator" 63 | msgstr "" 64 | 65 | #: manage.py:61 66 | msgid "Default" 67 | msgstr "" 68 | 69 | #: manage.py:65 70 | msgid "Test Conference" 71 | msgstr "" 72 | 73 | #: views.py:73 74 | msgid "Admin" 75 | msgstr "" 76 | 77 | #: views.py:75 78 | msgid "Marked" 79 | msgstr "" 80 | 81 | #: views.py:77 views.py:79 82 | msgid "PIN is set" 83 | msgstr "" 84 | 85 | #: views.py:81 86 | msgid "Wait for marked user to join" 87 | msgstr "" 88 | 89 | #: views.py:84 90 | msgid "End when marked user leaves" 91 | msgstr "" 92 | 93 | #: views.py:89 94 | msgid "Guests (not from participant list) can join" 95 | msgstr "" 96 | 97 | #: views.py:92 98 | msgid "Only for participants specified" 99 | msgstr "" 100 | 101 | #: views.py:109 views.py:179 102 | msgid "Phone" 103 | msgstr "" 104 | 105 | #: views.py:110 views.py:180 106 | msgid "Name" 107 | msgstr "" 108 | 109 | #: views.py:111 views.py:184 110 | msgid "User" 111 | msgstr "" 112 | 113 | #: views.py:114 views.py:155 114 | msgid "Add to Conference" 115 | msgstr "" 116 | 117 | #: views.py:140 118 | #, python-format 119 | msgid "Imported %(num)s contacts." 120 | msgstr "" 121 | 122 | #: views.py:181 views.py:484 views.py:833 123 | msgid "Conference" 124 | msgstr "" 125 | 126 | #: views.py:182 127 | msgid "Participant Profile" 128 | msgstr "" 129 | 130 | #: views.py:183 131 | msgid "Is invited on Invite All?" 132 | msgstr "" 133 | 134 | #: views.py:187 135 | msgid "" 136 | "When enabled this participant will be called on Invite All from " 137 | "Manage Conference menu." 138 | msgstr "" 139 | 140 | #: views.py:224 141 | msgid "Conference Number" 142 | msgstr "" 143 | 144 | #: views.py:225 145 | msgid "Conference Name" 146 | msgstr "" 147 | 148 | #: views.py:226 views.py:246 views.py:465 views.py:774 views.py:775 149 | #: views.py:784 views.py:785 views.py:795 views.py:805 150 | msgid "Participants" 151 | msgstr "" 152 | 153 | #: views.py:227 154 | msgid "Invited Participants" 155 | msgstr "" 156 | 157 | #: views.py:228 158 | msgid "Participants Online" 159 | msgstr "" 160 | 161 | #: views.py:229 162 | msgid "Locked" 163 | msgstr "" 164 | 165 | #: views.py:230 166 | msgid "Public" 167 | msgstr "" 168 | 169 | #: views.py:231 170 | msgid "Conference Profile" 171 | msgstr "" 172 | 173 | #: views.py:232 174 | msgid "Public Participant Profile" 175 | msgstr "" 176 | 177 | #: views.py:238 views.py:457 178 | msgid "Basic Settings" 179 | msgstr "" 180 | 181 | #: views.py:242 views.py:461 182 | msgid "Open Access" 183 | msgstr "" 184 | 185 | #: views.py:298 186 | #, python-format 187 | msgid "%(contact)s is already there." 188 | msgstr "" 189 | 190 | #: views.py:303 191 | #, python-format 192 | msgid "%(contact)s added." 193 | msgstr "" 194 | 195 | #: views.py:320 196 | #, python-format 197 | msgid "Number %(phone)s is called for conference." 198 | msgstr "" 199 | 200 | #: views.py:330 201 | msgid "All the participants where invited to the conference" 202 | msgstr "" 203 | 204 | #: views.py:343 205 | #, python-format 206 | msgid "Channel %(channel)s is kicked." 207 | msgstr "" 208 | 209 | #: views.py:348 210 | msgid "All participants have been kicked from the conference." 211 | msgstr "" 212 | 213 | #: views.py:362 214 | #, python-format 215 | msgid "Participant %(channel)s muted." 216 | msgstr "" 217 | 218 | #: views.py:369 219 | msgid "Conference muted." 220 | msgstr "" 221 | 222 | #: views.py:383 223 | #, python-format 224 | msgid "Participant %(channel)s unmuted." 225 | msgstr "" 226 | 227 | #: views.py:390 228 | msgid "Conference unmuted." 229 | msgstr "" 230 | 231 | #: views.py:402 232 | msgid "The conference recording has been started." 233 | msgstr "" 234 | 235 | #: views.py:412 236 | msgid "The conference recording has been stopped." 237 | msgstr "" 238 | 239 | #: views.py:422 240 | msgid "The conference has been locked." 241 | msgstr "" 242 | 243 | #: views.py:434 244 | msgid "The conference has been unlocked." 245 | msgstr "" 246 | 247 | #: views.py:483 248 | msgid "Entry" 249 | msgstr "" 250 | 251 | #: views.py:487 252 | msgid "" 253 | "Format: Minute Hour Day-of-Month Month Day-of-Week. Examples:
\n" 254 | "30 10 * * 1,2,3,4,5 - Every workday at 10:30 a.m.
\n" 255 | "0 10 1 * * - Every 1-st day of every month at 10:00 a.m.
\n" 256 | "See Linux Crontab: 15 Awesome Cron Job Examples -
\n" 257 | "http://www.thegeekstuff.com/2009/06/15-practical-crontab-examples/\n" 258 | msgstr "" 259 | 260 | #: views.py:513 261 | msgid "Crontab has been installed successfully." 262 | msgstr "" 263 | 264 | #: views.py:543 views.py:624 265 | msgid "Profile Name" 266 | msgstr "" 267 | 268 | #: views.py:546 269 | msgid "" 270 | "Limits the number of participants for a single conference to a specific " 271 | "number. By default, conferences have no participant limit. After the " 272 | "limit is reached, the conference will be locked until someone leaves. " 273 | "Admin-level users are exempt from this limit and will still be able to " 274 | "join otherwise-locked, because of limit, conferences." 275 | msgstr "" 276 | 277 | #: views.py:547 278 | msgid "" 279 | "Records the conference call starting when the first user enters the room," 280 | " and ending when the last user exits the room. The default recorded " 281 | "filename is 'confbridge--.wav' and" 282 | " the default format is 8kHz signed linear. By default, this option is " 283 | "disabled. This file will be located in the configured monitoring " 284 | "directory as set in conf" 285 | msgstr "" 286 | 287 | #: views.py:548 288 | msgid "" 289 | "Sets the internal native sample rate at which to mix the conference. The " 290 | "\"auto\" option allows Asterisk to adjust the sample rate to the best " 291 | "quality / performance based on the participant makeup. Numbered values " 292 | "lock the rate to the specified numerical rate. If a defined number does " 293 | "not match an internal sampling rate supported by Asterisk, the nearest " 294 | "sampling rate will be used instead." 295 | msgstr "" 296 | 297 | #: views.py:549 298 | msgid "" 299 | "Sets, in milliseconds, the internal mixing interval. By default, the " 300 | "mixing interval of a bridge is 20ms. This setting reflects how \"tight\" " 301 | "or \"loose\" the mixing will be for the conference. Lower intervals " 302 | "provide a \"tighter\" sound with less delay in the bridge and consume " 303 | "more system resources. Higher intervals provide a \"looser\" sound with " 304 | "more delay in the bridge and consume less resources" 305 | msgstr "" 306 | 307 | #: views.py:550 308 | msgid "" 309 | "Configured video (as opposed to audio) distribution method for conference" 310 | " participants. Participants must use the same video codec. Confbridge " 311 | "does not provide MCU functionality. It does not transcode, scale, " 312 | "transrate, or otherwise manipulate the video. Options are \"none,\" where" 313 | " no video source is set by default and a video source may be later set " 314 | "via AMI or DTMF actions; \"follow_talker,\" where video distrubtion " 315 | "follows whomever is talking and providing video; \"last_marked,\" where " 316 | "the last marked user with video capabilities to join the conference will " 317 | "be the single video source distributed to all other participants - when " 318 | "the current video source leaves, the marked user previous to the last-" 319 | "joined will be used as the video source; and \"first-marked,\" where the " 320 | "first marked user with video capabilities to join the conference will be " 321 | "the single video source distributed to all other participants - when the " 322 | "current video source leaves, the marked user that joined next will be " 323 | "used as the video source. Use of video in conjunction with the " 324 | "jitterbuffer results in the audio being slightly out of sync with the " 325 | "video - because the jitterbuffer only operates on the audio stream, not " 326 | "the video stream. Jitterbuffer should be disabled when video is used." 327 | msgstr "" 328 | 329 | #: views.py:599 330 | msgid "Basic" 331 | msgstr "" 332 | 333 | #: views.py:608 334 | msgid "Announcements" 335 | msgstr "" 336 | 337 | #: views.py:620 338 | msgid "Voice Processing" 339 | msgstr "" 340 | 341 | #: views.py:625 342 | msgid "Legend" 343 | msgstr "" 344 | 345 | #: views.py:639 346 | msgid "Sets if the user is an Admin or not. By default, no." 347 | msgstr "" 348 | 349 | #: views.py:640 350 | msgid "Sets if the user is Marked or not. By default, no." 351 | msgstr "" 352 | 353 | #: views.py:641 views.py:643 354 | msgid "Sets if the user should start out muted. By default, no." 355 | msgstr "" 356 | 357 | #: views.py:642 358 | msgid "" 359 | "Sets if the user must enter a PIN before joining the conference. The user" 360 | " will be prompted for the PIN." 361 | msgstr "" 362 | 363 | #: views.py:644 364 | msgid "" 365 | "When set, enter/leave prompts and user introductions are not played. By " 366 | "default, no." 367 | msgstr "" 368 | 369 | #: views.py:645 370 | msgid "" 371 | "Sets if the user must wait for another marked user to enter before " 372 | "joining the conference. By default, no." 373 | msgstr "" 374 | 375 | #: views.py:646 376 | msgid "" 377 | "If enabled, every user with this option in their profile will be removed " 378 | "from the conference when the last marked user exists the conference." 379 | msgstr "" 380 | 381 | #: views.py:647 382 | msgid "" 383 | "Whether or not DTMF received from users should pass through the " 384 | "conference to other users. By default, no." 385 | msgstr "" 386 | 387 | #: views.py:648 388 | msgid "" 389 | "Sets whether music on hold should be played when only one person is in " 390 | "the conference or when the user is waiting on a marked user to enter the " 391 | "conference. By default, off." 392 | msgstr "" 393 | 394 | #: views.py:649 395 | msgid "Sets the music on hold class to use for music on hold." 396 | msgstr "" 397 | 398 | #: views.py:650 399 | msgid "" 400 | "Sets if the number of users in the conference should be announced to the " 401 | "caller. By default, no." 402 | msgstr "" 403 | 404 | #: views.py:651 405 | msgid "" 406 | "Choices: yes, no, integer. Sets if the number of users should be " 407 | "announced to all other users in the conference when someone joins. When " 408 | "set to a number, the announcement will only occur once the user count is " 409 | "above the specified number" 410 | msgstr "" 411 | 412 | #: views.py:652 413 | msgid "" 414 | "Sets if the only user announcement should be played when someone enters " 415 | "an empty conference. By default, yes." 416 | msgstr "" 417 | 418 | #: views.py:653 419 | msgid "" 420 | "If set, the sound file specified by filename will be played to the user, " 421 | "and only the user, upon joining the conference bridge." 422 | msgstr "" 423 | 424 | #: views.py:654 425 | msgid "" 426 | "When enabled, this option prompts the user for their name when entering " 427 | "the conference. After the name is recorded, it will be played as the user" 428 | " enters and exists the conference. By default, no." 429 | msgstr "" 430 | 431 | #: views.py:655 432 | msgid "" 433 | "Drops what Asterisk detects as silence from entering into the bridge. " 434 | "Enabling this option will drastically improve performance and help remove" 435 | " the buildup of background noise from the conference. This option is " 436 | "highly recommended for large conferences, due to its performance " 437 | "improvements." 438 | msgstr "" 439 | 440 | #: views.py:656 441 | msgid "" 442 | "The time, in milliseconds, by default 160, of sound above what the DSP " 443 | "has established as base-line silence for a user, before that user is " 444 | "considered to be talking. This value affects several options:\n" 445 | "Audio is only mixed out of a user's incoming audio stream if talking is " 446 | "detected. If this value is set too loose, the user will hear themselves " 447 | "briefly each time they begin talking until the DSP has time to establish " 448 | "that they are in fact talking.\n" 449 | "When talker detection AMI events are enabled, this value determines when " 450 | "talking has begun, which causes AMI events to fire. If this value is set " 451 | "too tight, AMI events may be falsely triggered by variants in the " 452 | "background noise of the caller.\n" 453 | "The drop_silence option depends on this value to determine when the " 454 | "user's audio should be mixed into the bridge after periods of silence. If" 455 | " this value is too loose, the beginning of a user's speech will get cut " 456 | "off as they transition from silence to talking." 457 | msgstr "" 458 | 459 | #: views.py:660 460 | msgid "" 461 | "The time, in milliseconds, by default 2500, of sound falling within what " 462 | "the DSP has established as the baseline silence, before a user is " 463 | "considered to be silent. The best way to approach this option is to set " 464 | "it slightly above the maximum amount of milliseconds of silence a user " 465 | "may generate during natural speech. This value affects several " 466 | "operations:\n" 467 | "When talker detection AMI events are enabled, this value determines when " 468 | "the user has stopped talking after a period of talking. If this value is " 469 | "set too low, AMI events indicating that the user has stopped talking may " 470 | "get faslely sent out when the user briefly pauses during mid sentence.\n" 471 | "The drop_silence option depends on this value to determine when the " 472 | "user's audio should begin to be dropped from the bridge, after the user " 473 | "stops talking. If this value is set too low, the user's audio stream may " 474 | "sound choppy to other participants." 475 | msgstr "" 476 | 477 | #: views.py:663 478 | msgid "" 479 | "Sets whether or not notifications of when a user begins and ends talking " 480 | "should be sent out as events over AMI. By default, no." 481 | msgstr "" 482 | 483 | #: views.py:664 484 | msgid "" 485 | "Whether or not a noise reduction filter should be applied to the audio " 486 | "before mixing. By default, off. This requires codec_speex to be built and" 487 | " installed. Do not confuse this option with drop_silence. denoise is " 488 | "useful if there is a lot of background noise for a user, as it attempts " 489 | "to remove the noise while still preserving the speech. This option does " 490 | "not remove silence from being mixed into the conference and does come at " 491 | "the cost of a slight performance hit." 492 | msgstr "" 493 | 494 | #: views.py:665 495 | msgid "" 496 | "Whether or not to place a jitter buffer on the caller's audio stream " 497 | "before any audio mixing is performed. This option is highly recommended, " 498 | "but will add a slight delay to the audio and will incur a slight " 499 | "performance penalty. This option makes use of the JITTERBUFFER dialplan " 500 | "function's default adaptive jitter buffer. For a more fine-tuned jitter " 501 | "buffer, disable this option and use the JITTERBUFFER dialplan function on" 502 | " the calling channel, before it enters the ConfBridge application." 503 | msgstr "" 504 | 505 | #: views.py:685 506 | msgid "New Password" 507 | msgstr "" 508 | 509 | #: views.py:734 views.py:735 views.py:744 views.py:745 views.py:754 510 | #: views.py:764 511 | msgid "Conferences" 512 | msgstr "" 513 | 514 | #: views.py:755 views.py:765 515 | msgid "Plan" 516 | msgstr "" 517 | 518 | #: views.py:794 views.py:804 519 | msgid "Contacts" 520 | msgstr "" 521 | 522 | #: views.py:813 523 | msgid "Recordings" 524 | msgstr "" 525 | 526 | #: views.py:820 views.py:830 527 | msgid "Profiles" 528 | msgstr "" 529 | 530 | #: views.py:823 531 | msgid "Participant" 532 | msgstr "" 533 | 534 | #: views.py:838 views.py:841 views.py:846 535 | msgid "Users" 536 | msgstr "" 537 | 538 | #: views.py:849 539 | msgid "Roles" 540 | msgstr "" 541 | 542 | #: views.py:904 543 | #, python-format 544 | msgid "Attempt to enter non-public conference from %(phone)s." 545 | msgstr "" 546 | 547 | #: views.py:946 548 | #, python-format 549 | msgid "Could not invite number %(num)s: %(status)s" 550 | msgstr "" 551 | 552 | #: views.py:956 553 | #, python-format 554 | msgid "Number %(num)s has entered the conference." 555 | msgstr "" 556 | 557 | #: views.py:966 558 | #, python-format 559 | msgid "Number %(num)s has left the conference." 560 | msgstr "" 561 | 562 | #: views.py:980 563 | #, python-format 564 | msgid "Unmute request from number %(num)s." 565 | msgstr "" 566 | 567 | #: views.py:988 568 | #, python-format 569 | msgid "Number %(num)s is talking." 570 | msgstr "" 571 | 572 | #: views.py:996 573 | #, python-format 574 | msgid "Number %(num)s is silent." 575 | msgstr "" 576 | 577 | #: views.py:1010 578 | #, python-format 579 | msgid "" 580 | "wget -O - --no-proxy " 581 | "http://localhost:5000/asterisk/get_talkers_on/%(conf)s/%(num)s " 582 | "2>/dev/null" 583 | msgstr "" 584 | 585 | #: views.py:1022 586 | #, python-format 587 | msgid "" 588 | "wget -O - --no-proxy " 589 | "http://localhost:5000/asterisk/get_talkers_off/%(conf)s/%(num)s " 590 | "2>/dev/null" 591 | msgstr "" 592 | 593 | #: templates/action_conference.html:47 594 | msgid "Submit" 595 | msgstr "" 596 | 597 | #: templates/conference_create.html:7 598 | msgid "You can create participants in menu Participants." 599 | msgstr "" 600 | 601 | #: templates/conference_details.html:88 602 | msgid "Manage Conference" 603 | msgstr "" 604 | 605 | #: templates/conference_details.html:92 606 | msgid "Invite All Participants" 607 | msgstr "" 608 | 609 | #: templates/conference_details.html:96 610 | msgid "Phone number" 611 | msgstr "" 612 | 613 | #: templates/conference_details.html:99 templates/conference_details.html:166 614 | msgid "Invite" 615 | msgstr "" 616 | 617 | #: templates/conference_details.html:103 618 | msgid "Mute All" 619 | msgstr "" 620 | 621 | #: templates/conference_details.html:104 622 | msgid "Unmute All" 623 | msgstr "" 624 | 625 | #: templates/conference_details.html:105 626 | msgid "Start Recording" 627 | msgstr "" 628 | 629 | #: templates/conference_details.html:106 630 | msgid "Stop Recording" 631 | msgstr "" 632 | 633 | #: templates/conference_details.html:108 634 | msgid "Unlock" 635 | msgstr "" 636 | 637 | #: templates/conference_details.html:110 638 | msgid "Lock" 639 | msgstr "" 640 | 641 | #: templates/conference_details.html:112 642 | msgid "Kick All" 643 | msgstr "" 644 | 645 | #: templates/conference_details.html:135 646 | msgid "Unmute" 647 | msgstr "" 648 | 649 | #: templates/conference_details.html:139 650 | msgid "Mute" 651 | msgstr "" 652 | 653 | #: templates/conference_details.html:167 654 | msgid "Kick" 655 | msgstr "" 656 | 657 | #: templates/conference_details.html:226 658 | msgid "Conference Log" 659 | msgstr "" 660 | 661 | #: templates/conference_details.html:226 662 | msgid "Clear Log" 663 | msgstr "" 664 | 665 | #: templates/conference_edit.html:8 666 | msgid "You can manage participants in menu Participants." 667 | msgstr "" 668 | 669 | #: templates/conference_schedule_list.html:5 670 | msgid "Install the Schedule" 671 | msgstr "" 672 | 673 | #: templates/contact_create.html:5 674 | msgid "You can also" 675 | msgstr "" 676 | 677 | #: templates/contact_create.html:5 678 | msgid "Import Contacts" 679 | msgstr "" 680 | 681 | #: templates/contact_import.html:5 682 | msgid "Import Contacts from a CSV file (phone, name):" 683 | msgstr "" 684 | 685 | #: templates/my_master.html:13 686 | msgid "Logout" 687 | msgstr "" 688 | 689 | #: templates/my_master.html:17 690 | msgid "Login" 691 | msgstr "" 692 | 693 | #: templates/my_master.html:22 694 | msgid "Language" 695 | msgstr "" 696 | 697 | #: utils/validators.py:10 698 | msgid "Must be a number!" 699 | msgstr "" 700 | 701 | #: utils/validators.py:19 702 | #, python-format 703 | msgid "Participant with phone number %(num)s already there." 704 | msgstr "" 705 | 706 | #: utils/validators.py:34 707 | #, python-format 708 | msgid "%(job)s is not a correct crontab entry." 709 | msgstr "" 710 | 711 | -------------------------------------------------------------------------------- /astconfman/translations/ru/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # Russian translations for PROJECT. 2 | # Copyright (C) 2016 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2016. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2022-02-18 09:51+0000\n" 11 | "PO-Revision-Date: 2016-02-12 23:28+0200\n" 12 | "Last-Translator: \n" 13 | "Language: ru\n" 14 | "Language-Team: ru \n" 15 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " 16 | "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=utf-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Generated-By: Babel 2.9.1\n" 21 | 22 | #: config.py:14 23 | msgid "" 24 | "(C) 2015 Asterisk Guru | www.asteriskguru.ru | Professional " 26 | "Asterisk support & development services." 27 | msgstr "" 28 | "(C) 2015 Asterisk Guru | www.asteriskguru.ru | " 30 | "Профессиональные услуги поддержки и разработки для Asterisk." 31 | 32 | #: forms.py:11 33 | msgid "File" 34 | msgstr "Файл" 35 | 36 | #: forms.py:20 37 | #, python-format 38 | msgid "CSV file is broken, line %(linenum)s" 39 | msgstr "CSV-файл поврежден, line %(linenum)s" 40 | 41 | #: forms.py:24 42 | #, python-format 43 | msgid "The first column does not contain phone number, line %(linenum)s" 44 | msgstr "Первый столбец не содержит номер телефона, line %(linenum)s" 45 | 46 | #: forms.py:36 47 | msgid "You must select a Public Participant Profile for a Public Conference." 48 | msgstr "Вы должны выбрать профиль участника для Публичной Конференции" 49 | 50 | #: manage.py:47 51 | msgid "John Smith" 52 | msgstr "Афиноген Пупкин" 53 | 54 | #: manage.py:48 55 | msgid "Sam Brown" 56 | msgstr "Космодром Байконуров" 57 | 58 | #: manage.py:54 59 | msgid "Guest" 60 | msgstr "Гость" 61 | 62 | #: manage.py:56 63 | msgid "Marker" 64 | msgstr "Помеченный пользвователь" 65 | 66 | #: manage.py:58 67 | msgid "Administrator" 68 | msgstr "Администратор" 69 | 70 | #: manage.py:61 71 | msgid "Default" 72 | msgstr "По умолчанию" 73 | 74 | #: manage.py:65 75 | msgid "Test Conference" 76 | msgstr "Тестовая конференция" 77 | 78 | #: views.py:73 79 | msgid "Admin" 80 | msgstr "Администратор" 81 | 82 | #: views.py:75 83 | msgid "Marked" 84 | msgstr "Помеченный пользователь" 85 | 86 | #: views.py:77 views.py:79 87 | msgid "PIN is set" 88 | msgstr "ПИН установлен" 89 | 90 | #: views.py:81 91 | msgid "Wait for marked user to join" 92 | msgstr "Ожидать присоединения к конференции Помеченного Пользователя" 93 | 94 | #: views.py:84 95 | msgid "End when marked user leaves" 96 | msgstr "Завершить как только Помеченный Пользователь вышел" 97 | 98 | #: views.py:89 99 | msgid "Guests (not from participant list) can join" 100 | msgstr "Гости (номера, которых нет в списке участников) могут присоединиться" 101 | 102 | #: views.py:92 103 | msgid "Only for participants specified" 104 | msgstr "Только для участников" 105 | 106 | #: views.py:109 views.py:179 107 | msgid "Phone" 108 | msgstr "Телефон" 109 | 110 | #: views.py:110 views.py:180 111 | msgid "Name" 112 | msgstr "Имя" 113 | 114 | #: views.py:111 views.py:184 115 | msgid "User" 116 | msgstr "Пользователь" 117 | 118 | #: views.py:114 views.py:155 119 | msgid "Add to Conference" 120 | msgstr "Добавить в конференцию" 121 | 122 | #: views.py:140 123 | #, python-format 124 | msgid "Imported %(num)s contacts." 125 | msgstr "Импортировано %(num)s контактов" 126 | 127 | #: views.py:181 views.py:484 views.py:833 128 | msgid "Conference" 129 | msgstr "Конференция" 130 | 131 | #: views.py:182 132 | msgid "Participant Profile" 133 | msgstr "Профиль Участника" 134 | 135 | #: views.py:183 136 | msgid "Is invited on Invite All?" 137 | msgstr "Включен в Пригласить Всех" 138 | 139 | #: views.py:187 140 | msgid "" 141 | "When enabled this participant will be called on Invite All from " 142 | "Manage Conference menu." 143 | msgstr "" 144 | "Если включено, этот участник будет вызван при выборе Пригласить Всех " 145 | "Участников в меню Управления Конфренецией." 146 | 147 | #: views.py:224 148 | msgid "Conference Number" 149 | msgstr "Номер Конференции" 150 | 151 | #: views.py:225 152 | msgid "Conference Name" 153 | msgstr "Название Конференции" 154 | 155 | #: views.py:226 views.py:246 views.py:465 views.py:774 views.py:775 156 | #: views.py:784 views.py:785 views.py:795 views.py:805 157 | msgid "Participants" 158 | msgstr "Участники" 159 | 160 | #: views.py:227 161 | msgid "Invited Participants" 162 | msgstr "Приглашаемые участники" 163 | 164 | #: views.py:228 165 | msgid "Participants Online" 166 | msgstr "Участники онлайн" 167 | 168 | #: views.py:229 169 | msgid "Locked" 170 | msgstr "Заблокирована" 171 | 172 | #: views.py:230 173 | msgid "Public" 174 | msgstr "Публичная" 175 | 176 | #: views.py:231 177 | msgid "Conference Profile" 178 | msgstr "Профиль Конференции" 179 | 180 | #: views.py:232 181 | msgid "Public Participant Profile" 182 | msgstr "Профиль Публичного Участника" 183 | 184 | #: views.py:238 views.py:457 185 | msgid "Basic Settings" 186 | msgstr "Основные настройки" 187 | 188 | #: views.py:242 views.py:461 189 | msgid "Open Access" 190 | msgstr "Открытый доступ" 191 | 192 | #: views.py:298 193 | #, python-format 194 | msgid "%(contact)s is already there." 195 | msgstr "%(contact)s уже там." 196 | 197 | #: views.py:303 198 | #, python-format 199 | msgid "%(contact)s added." 200 | msgstr "%(contact)s добавлен." 201 | 202 | #: views.py:320 203 | #, python-format 204 | msgid "Number %(phone)s is called for conference." 205 | msgstr "Номер %(phone)s вызван в конференцию." 206 | 207 | #: views.py:330 208 | msgid "All the participants where invited to the conference" 209 | msgstr "Все участники были приглашены в конференцию" 210 | 211 | #: views.py:343 212 | #, python-format 213 | msgid "Channel %(channel)s is kicked." 214 | msgstr "Канал %(channel)s выброшен из конференции." 215 | 216 | #: views.py:348 217 | msgid "All participants have been kicked from the conference." 218 | msgstr "Все участники были отключены от конференции." 219 | 220 | #: views.py:362 221 | #, python-format 222 | msgid "Participant %(channel)s muted." 223 | msgstr "Участник %(channel)s - микрофон выключен." 224 | 225 | #: views.py:369 226 | msgid "Conference muted." 227 | msgstr "В конференции приглушен звук." 228 | 229 | #: views.py:383 230 | #, python-format 231 | msgid "Participant %(channel)s unmuted." 232 | msgstr "Участник %(channel)s - микрофон включен." 233 | 234 | #: views.py:390 235 | msgid "Conference unmuted." 236 | msgstr "В конференции включен звук." 237 | 238 | #: views.py:402 239 | msgid "The conference recording has been started." 240 | msgstr "Запись конференции начата." 241 | 242 | #: views.py:412 243 | msgid "The conference recording has been stopped." 244 | msgstr "Запись конференции закончена." 245 | 246 | #: views.py:422 247 | msgid "The conference has been locked." 248 | msgstr "Конференция заблокирована." 249 | 250 | #: views.py:434 251 | msgid "The conference has been unlocked." 252 | msgstr "Конференция разблокирована" 253 | 254 | #: views.py:483 255 | msgid "Entry" 256 | msgstr "Значение" 257 | 258 | #: views.py:487 259 | msgid "" 260 | "Format: Minute Hour Day-of-Month Month Day-of-Week. Examples:
\n" 261 | "30 10 * * 1,2,3,4,5 - Every workday at 10:30 a.m.
\n" 262 | "0 10 1 * * - Every 1-st day of every month at 10:00 a.m.
\n" 263 | "See Linux Crontab: 15 Awesome Cron Job Examples -
\n" 264 | "http://www.thegeekstuff.com/2009/06/15-practical-crontab-examples/\n" 265 | msgstr "" 266 | "Формат: Минута Час Число-месяца Месяц День-недели. Примеры:
\n" 267 | "30 10 * * 1,2,3,4,5 - Каждый рабочий день в 10:30 утра.
\n" 268 | "0 10 1 * * - Каждый первый день месяца в 10:00 утра.
\n" 269 | "Смотрите статью Linux Crontab: 15 Крутых Примеров Крон Задач -
\n" 270 | "http://www.thegeekstuff.com/2009/06/15-practical-crontab-examples/\n" 271 | 272 | #: views.py:513 273 | msgid "Crontab has been installed successfully." 274 | msgstr "Crontab успешно установлен." 275 | 276 | #: views.py:543 views.py:624 277 | msgid "Profile Name" 278 | msgstr "Название Профиля" 279 | 280 | #: views.py:546 281 | msgid "" 282 | "Limits the number of participants for a single conference to a specific " 283 | "number. By default, conferences have no participant limit. After the " 284 | "limit is reached, the conference will be locked until someone leaves. " 285 | "Admin-level users are exempt from this limit and will still be able to " 286 | "join otherwise-locked, because of limit, conferences." 287 | msgstr "" 288 | "Ограничить количества участников в конкретной конференц-комнате. По " 289 | "умолчанию, конференции не имеют лимита на участников. После достижения " 290 | "лимит, конференция будет заблокирована на вход пользователей, пока кто-то" 291 | " не выйдет из нее. Пользователи с профилем Админитратора не попадают опд " 292 | "действие лимита и всегда могут попасть в нужную конференц-комнату." 293 | 294 | #: views.py:547 295 | msgid "" 296 | "Records the conference call starting when the first user enters the room," 297 | " and ending when the last user exits the room. The default recorded " 298 | "filename is 'confbridge--.wav' and" 299 | " the default format is 8kHz signed linear. By default, this option is " 300 | "disabled. This file will be located in the configured monitoring " 301 | "directory as set in conf" 302 | msgstr "" 303 | "Запись конференции начинается при входе первого пользователя в " 304 | "конференц-комнату и заканчивается когда выйдет последний. Имя файла " 305 | "записи по умолчанию \"-.wav\", " 306 | "8кГц. По умолчанию опция записи отключена. Файл записи будет находится в " 307 | "папки записей, которая указана в конфигурационном файле asterisk.conf" 308 | 309 | #: views.py:548 310 | msgid "" 311 | "Sets the internal native sample rate at which to mix the conference. The " 312 | "\"auto\" option allows Asterisk to adjust the sample rate to the best " 313 | "quality / performance based on the participant makeup. Numbered values " 314 | "lock the rate to the specified numerical rate. If a defined number does " 315 | "not match an internal sampling rate supported by Asterisk, the nearest " 316 | "sampling rate will be used instead." 317 | msgstr "" 318 | "Установить чистоту дискритезации, на которой микшировать конференцию. " 319 | "Опция \"авто\" позволяет Asterisk устанавливает нужную частоту " 320 | "дискретизации для достижения максимального качества записи. Числовые " 321 | "значения устанавливают частоту дискретизации равную их значению. Если " 322 | "выбранное числовое значение не совпадает с имеющейся по умолчанию в " 323 | "Asterisk частотой дискретизации - выбирается ближайшее имеющееся " 324 | "значение." 325 | 326 | #: views.py:549 327 | msgid "" 328 | "Sets, in milliseconds, the internal mixing interval. By default, the " 329 | "mixing interval of a bridge is 20ms. This setting reflects how \"tight\" " 330 | "or \"loose\" the mixing will be for the conference. Lower intervals " 331 | "provide a \"tighter\" sound with less delay in the bridge and consume " 332 | "more system resources. Higher intervals provide a \"looser\" sound with " 333 | "more delay in the bridge and consume less resources" 334 | msgstr "" 335 | "Установить интервал микширования (в милисекундах). По умолчанию интервал " 336 | "микширования - 20мс. Данный параметр устанавливает насколько \"жестко \"" 337 | " или \"свободно \" будет микшироваться запись конфренции. Нижние " 338 | "значения обеспечивают более \"жесткий \" звук и потребляют большее " 339 | "количество системных ресурсов, более высокие, соответственно, наоборот." 340 | 341 | #: views.py:550 342 | msgid "" 343 | "Configured video (as opposed to audio) distribution method for conference" 344 | " participants. Participants must use the same video codec. Confbridge " 345 | "does not provide MCU functionality. It does not transcode, scale, " 346 | "transrate, or otherwise manipulate the video. Options are \"none,\" where" 347 | " no video source is set by default and a video source may be later set " 348 | "via AMI or DTMF actions; \"follow_talker,\" where video distrubtion " 349 | "follows whomever is talking and providing video; \"last_marked,\" where " 350 | "the last marked user with video capabilities to join the conference will " 351 | "be the single video source distributed to all other participants - when " 352 | "the current video source leaves, the marked user previous to the last-" 353 | "joined will be used as the video source; and \"first-marked,\" where the " 354 | "first marked user with video capabilities to join the conference will be " 355 | "the single video source distributed to all other participants - when the " 356 | "current video source leaves, the marked user that joined next will be " 357 | "used as the video source. Use of video in conjunction with the " 358 | "jitterbuffer results in the audio being slightly out of sync with the " 359 | "video - because the jitterbuffer only operates on the audio stream, not " 360 | "the video stream. Jitterbuffer should be disabled when video is used." 361 | msgstr "" 362 | "Настроить видео- (отдельно от аудио-) профиль для участников конференции." 363 | " Участники конференции должны использовать такой же кодек. Confbridge не " 364 | "обладает функционалом полноценного MCU. Он не транскодирует, не " 365 | "масштабирует, не изменяет битрейт видео-потока. Варианты:\"none\" - видео" 366 | " не включено по умолчанию. Но может быть включено позже используя " 367 | "DTMF-команды или AMI-интерфейс Asterisk.\"follow_talker\" - показывать " 368 | "говорящего,\"last_marked\" - показывать последнего Помеченного " 369 | "пользователя, после того как данный пользоователь вышел из конференции, " 370 | "источником видео будет предыдущий помеченный пользователь.\"first-" 371 | "marked\" - показывать первого Помеченного пользователя, как только этот " 372 | "пользователь вышел, источником видео будет следующий зашедший в " 373 | "конференцию Помеченый пользователь. Для использования аудио- и " 374 | "видео-потоков в конференции параметр Asterisk Jitterbuffer должен быть " 375 | "выключен, чтобы не было рассинхронизации картинки и звука." 376 | 377 | #: views.py:599 378 | msgid "Basic" 379 | msgstr "Основные" 380 | 381 | #: views.py:608 382 | msgid "Announcements" 383 | msgstr "Объявления" 384 | 385 | #: views.py:620 386 | msgid "Voice Processing" 387 | msgstr "Обработка голоса" 388 | 389 | #: views.py:625 390 | msgid "Legend" 391 | msgstr "Легенда" 392 | 393 | #: views.py:639 394 | msgid "Sets if the user is an Admin or not. By default, no." 395 | msgstr "Установить пользователю профиль Администратор. По умолчанию - нет." 396 | 397 | #: views.py:640 398 | msgid "Sets if the user is Marked or not. By default, no." 399 | msgstr "" 400 | "Установить пользователю профиль Помеченный пользователь. По умолчанию - " 401 | "нет." 402 | 403 | #: views.py:641 views.py:643 404 | msgid "Sets if the user should start out muted. By default, no." 405 | msgstr "" 406 | "Установить пользователю заход в конференцию с выключенным микрофоном. По " 407 | "умолчанию - нет." 408 | 409 | #: views.py:642 410 | msgid "" 411 | "Sets if the user must enter a PIN before joining the conference. The user" 412 | " will be prompted for the PIN." 413 | msgstr "" 414 | "Установить пользователю принудительный ввод ПИН-кода перед заходом в " 415 | "конференц-комнату. (При входе пользователю будет проиграно сообщение с " 416 | "просьюой ввести ПИН)." 417 | 418 | #: views.py:644 419 | msgid "" 420 | "When set, enter/leave prompts and user introductions are not played. By " 421 | "default, no." 422 | msgstr "" 423 | "Если установлено, то сообщения о входе/выходе пользователя в " 424 | "конференц-комнату не проигрываются. По умолчанию - нет." 425 | 426 | #: views.py:645 427 | msgid "" 428 | "Sets if the user must wait for another marked user to enter before " 429 | "joining the conference. By default, no." 430 | msgstr "" 431 | "Устанавливает, должен ли пользователь ожидать входа Помеченного " 432 | "пользователя перед присоединением пользователя к конференции. По " 433 | "умолчанию - нет." 434 | 435 | #: views.py:646 436 | msgid "" 437 | "If enabled, every user with this option in their profile will be removed " 438 | "from the conference when the last marked user exists the conference." 439 | msgstr "" 440 | "Если опция включена, каждый пользователь с этой опцией в своем профиле, " 441 | "будет удален из конференции, когда в конференции остался один Помеченный " 442 | "пользователь пользователь." 443 | 444 | #: views.py:647 445 | msgid "" 446 | "Whether or not DTMF received from users should pass through the " 447 | "conference to other users. By default, no." 448 | msgstr "Разрешить DTMF от пользователей в конференции. По умолчанию - нет." 449 | 450 | #: views.py:648 451 | msgid "" 452 | "Sets whether music on hold should be played when only one person is in " 453 | "the conference or when the user is waiting on a marked user to enter the " 454 | "conference. By default, off." 455 | msgstr "" 456 | "Устанавливает должна ли проигрываться музыка на удержании если в " 457 | "конференц-комнате единственный пользователь или когда участники ожидают " 458 | "вход в конференцию Помеченного пользователя. По умолчанию - выключено." 459 | 460 | #: views.py:649 461 | msgid "Sets the music on hold class to use for music on hold." 462 | msgstr "Устанавливает класс музыки на удержании для конференции." 463 | 464 | #: views.py:650 465 | msgid "" 466 | "Sets if the number of users in the conference should be announced to the " 467 | "caller. By default, no." 468 | msgstr "" 469 | "Устанавливает нужно ли проигрывать текущее количество участников " 470 | "конференции пользователю при входе в конференц-комнату. По умолчанию - " 471 | "нет." 472 | 473 | #: views.py:651 474 | msgid "" 475 | "Choices: yes, no, integer. Sets if the number of users should be " 476 | "announced to all other users in the conference when someone joins. When " 477 | "set to a number, the announcement will only occur once the user count is " 478 | "above the specified number" 479 | msgstr "Выбор: yes, no, число. " 480 | 481 | #: views.py:652 482 | msgid "" 483 | "Sets if the only user announcement should be played when someone enters " 484 | "an empty conference. By default, yes." 485 | msgstr "" 486 | "Устанавливает должно ли проигрываться сообщение о заходе пользователя в " 487 | "конференцию. По умолчанию - да." 488 | 489 | #: views.py:653 490 | msgid "" 491 | "If set, the sound file specified by filename will be played to the user, " 492 | "and only the user, upon joining the conference bridge." 493 | msgstr "" 494 | "Если установлено, звуковой файл с определенным именем будет проигран " 495 | "пользователю при присоединении к конференции" 496 | 497 | #: views.py:654 498 | msgid "" 499 | "When enabled, this option prompts the user for their name when entering " 500 | "the conference. After the name is recorded, it will be played as the user" 501 | " enters and exists the conference. By default, no." 502 | msgstr "" 503 | "Когда эта опция включена, у пользователя запрашивается его имя при входе " 504 | "в конференцию. После этого записывается то, что сказал пользовательи " 505 | "проигрывается при его входе в конференцию. По умолчанию - нет." 506 | 507 | #: views.py:655 508 | msgid "" 509 | "Drops what Asterisk detects as silence from entering into the bridge. " 510 | "Enabling this option will drastically improve performance and help remove" 511 | " the buildup of background noise from the conference. This option is " 512 | "highly recommended for large conferences, due to its performance " 513 | "improvements." 514 | msgstr "" 515 | "Включение этой опции резко повышает производительность и поможет удалить " 516 | "наращивание фонового шума от конференции. Эта опция рекомендуется для " 517 | "больших конференций ввиду улучшения производительности." 518 | 519 | #: views.py:656 520 | msgid "" 521 | "The time, in milliseconds, by default 160, of sound above what the DSP " 522 | "has established as base-line silence for a user, before that user is " 523 | "considered to be talking. This value affects several options:\n" 524 | "Audio is only mixed out of a user's incoming audio stream if talking is " 525 | "detected. If this value is set too loose, the user will hear themselves " 526 | "briefly each time they begin talking until the DSP has time to establish " 527 | "that they are in fact talking.\n" 528 | "When talker detection AMI events are enabled, this value determines when " 529 | "talking has begun, which causes AMI events to fire. If this value is set " 530 | "too tight, AMI events may be falsely triggered by variants in the " 531 | "background noise of the caller.\n" 532 | "The drop_silence option depends on this value to determine when the " 533 | "user's audio should be mixed into the bridge after periods of silence. If" 534 | " this value is too loose, the beginning of a user's speech will get cut " 535 | "off as they transition from silence to talking." 536 | msgstr "" 537 | "Время в миллисекундах (160 по умолчанию), которое согласно DSP " 538 | "предшествует началу разговора пользователя. Это значание влияет на " 539 | "некоторые операции: Аудио микшируется только из поступающего звукового " 540 | "потока пользователя, если речь обнаружена. Если значение слишком низкое " 541 | "- пользователи будут слышать себя каждый раз, когда они начинают " 542 | "говорить. Также это значение влияет на правильное создание " 543 | "соответсвующего AMI-событий. Если значение слишком большое - то начало " 544 | "разговора пользователя может быть не услышано." 545 | 546 | #: views.py:660 547 | msgid "" 548 | "The time, in milliseconds, by default 2500, of sound falling within what " 549 | "the DSP has established as the baseline silence, before a user is " 550 | "considered to be silent. The best way to approach this option is to set " 551 | "it slightly above the maximum amount of milliseconds of silence a user " 552 | "may generate during natural speech. This value affects several " 553 | "operations:\n" 554 | "When talker detection AMI events are enabled, this value determines when " 555 | "the user has stopped talking after a period of talking. If this value is " 556 | "set too low, AMI events indicating that the user has stopped talking may " 557 | "get faslely sent out when the user briefly pauses during mid sentence.\n" 558 | "The drop_silence option depends on this value to determine when the " 559 | "user's audio should begin to be dropped from the bridge, after the user " 560 | "stops talking. If this value is set too low, the user's audio stream may " 561 | "sound choppy to other participants." 562 | msgstr "" 563 | "Продолжительность тишины, которая считается DSP базовой для того, чтобы " 564 | "считать, что от пользователя не приходит никакого аудио-потока, по " 565 | "умолчанию 2500 милисекунд. Best practice - установить это значение чуть " 566 | "выше значения по умолчанию, нежели значение, которое может быть " 567 | "достигнуто в естественной речи пользователя. Это значание влияет на " 568 | "некоторые события: Когда AMI-событие детектирования говорящего включено, " 569 | "это занчание определяет, когда пользователь закончил говорить. Если " 570 | "значение слишком низкое, то может быть ложное AMI-событие. Опция " 571 | "drop_silence зависит от данной опции. Если значание слишком низкое речь " 572 | "пользователя для других участников конференции может слышиться " 573 | "прерывистой" 574 | 575 | #: views.py:663 576 | msgid "" 577 | "Sets whether or not notifications of when a user begins and ends talking " 578 | "should be sent out as events over AMI. By default, no." 579 | msgstr "" 580 | "Устанавливает отправку AMI-событий когда пользователь начал или закончил " 581 | "говорить. По умолчанию - нет." 582 | 583 | #: views.py:664 584 | msgid "" 585 | "Whether or not a noise reduction filter should be applied to the audio " 586 | "before mixing. By default, off. This requires codec_speex to be built and" 587 | " installed. Do not confuse this option with drop_silence. denoise is " 588 | "useful if there is a lot of background noise for a user, as it attempts " 589 | "to remove the noise while still preserving the speech. This option does " 590 | "not remove silence from being mixed into the conference and does come at " 591 | "the cost of a slight performance hit." 592 | msgstr "" 593 | "Устанавливает должно ли быть включено шумоподавление перед микшированием " 594 | "аудиопотока. По умолчанию - выключено. Для использования данной опции " 595 | "codec_speex должен быть скомпилирован и установлен. Не путайте эту опцию " 596 | "с drop_silence, удаление шумов полезно, если есть много фонового шума, " 597 | "так как включение этой опции позвоялет удалить шум, сохраняя речь. Эта " 598 | "опция не удаляет тишину из микширования в конференции и в конечном итоге " 599 | "приводит к небольшому падению производительности." 600 | 601 | #: views.py:665 602 | msgid "" 603 | "Whether or not to place a jitter buffer on the caller's audio stream " 604 | "before any audio mixing is performed. This option is highly recommended, " 605 | "but will add a slight delay to the audio and will incur a slight " 606 | "performance penalty. This option makes use of the JITTERBUFFER dialplan " 607 | "function's default adaptive jitter buffer. For a more fine-tuned jitter " 608 | "buffer, disable this option and use the JITTERBUFFER dialplan function on" 609 | " the calling channel, before it enters the ConfBridge application." 610 | msgstr "" 611 | "Указывать или не указывать значение джиттер-буффера на голосовом потоке " 612 | "звонящего до того как будут совершены какие-либо преобразования. " 613 | "Использование опции рекомендуется, однако добавляет небольшую задержку в " 614 | "передаче голоса и слегка снижает производительность системы. Эта опция " 615 | "позволяет использовать адаптивный jitter buffer по умолчанию в функции " 616 | "диалплана JITTERBUFFER . Для более точной настройки jitter-buffer " 617 | "требуется отключение опции и применение функции диалплана JITTERBUFFER на" 618 | " вызываемом канале до того как вызов попадет в приложение ConfBridge." 619 | 620 | #: views.py:685 621 | msgid "New Password" 622 | msgstr "Новый Пароль" 623 | 624 | #: views.py:734 views.py:735 views.py:744 views.py:745 views.py:754 625 | #: views.py:764 626 | msgid "Conferences" 627 | msgstr "Конференции" 628 | 629 | #: views.py:755 views.py:765 630 | msgid "Plan" 631 | msgstr "План" 632 | 633 | #: views.py:794 views.py:804 634 | msgid "Contacts" 635 | msgstr "Контакты" 636 | 637 | #: views.py:813 638 | msgid "Recordings" 639 | msgstr "Записи" 640 | 641 | #: views.py:820 views.py:830 642 | msgid "Profiles" 643 | msgstr "Профили" 644 | 645 | #: views.py:823 646 | msgid "Participant" 647 | msgstr "Участник" 648 | 649 | #: views.py:838 views.py:841 views.py:846 650 | msgid "Users" 651 | msgstr "Пользователи" 652 | 653 | #: views.py:849 654 | msgid "Roles" 655 | msgstr "Роли" 656 | 657 | #: views.py:904 658 | #, python-format 659 | msgid "Attempt to enter non-public conference from %(phone)s." 660 | msgstr "Попытка войти в не-публичную конференцию с номера %(phone)s." 661 | 662 | #: views.py:946 663 | #, python-format 664 | msgid "Could not invite number %(num)s: %(status)s" 665 | msgstr "Невозможно вызвать номер %(num)s: %(status)s" 666 | 667 | #: views.py:956 668 | #, python-format 669 | msgid "Number %(num)s has entered the conference." 670 | msgstr "Номер %(num)s вошел в конференцию." 671 | 672 | #: views.py:966 673 | #, python-format 674 | msgid "Number %(num)s has left the conference." 675 | msgstr "Номер %(num)s покинул конференцию." 676 | 677 | #: views.py:980 678 | #, python-format 679 | msgid "Unmute request from number %(num)s." 680 | msgstr "Запрос на включение микрофона от номера %(num)s." 681 | 682 | #: views.py:988 683 | #, python-format 684 | msgid "Number %(num)s is talking." 685 | msgstr "Номер %(num)s говорит" 686 | 687 | #: views.py:996 688 | #, python-format 689 | msgid "Number %(num)s is silent." 690 | msgstr "Номер %(num)s замолчал" 691 | 692 | #: views.py:1010 693 | #, python-format 694 | msgid "" 695 | "wget -O - --no-proxy " 696 | "http://localhost:5000/asterisk/get_talkers_on/%(conf)s/%(num)s " 697 | "2>/dev/null" 698 | msgstr "" 699 | 700 | #: views.py:1022 701 | #, python-format 702 | msgid "" 703 | "wget -O - --no-proxy " 704 | "http://localhost:5000/asterisk/get_talkers_off/%(conf)s/%(num)s " 705 | "2>/dev/null" 706 | msgstr "" 707 | 708 | #: templates/action_conference.html:47 709 | msgid "Submit" 710 | msgstr "Подтвердить" 711 | 712 | #: templates/conference_create.html:7 713 | msgid "You can create participants in menu Participants." 714 | msgstr "Создайте Участников конференции в меню Участники." 715 | 716 | #: templates/conference_details.html:88 717 | msgid "Manage Conference" 718 | msgstr "Управление конференцией" 719 | 720 | #: templates/conference_details.html:92 721 | msgid "Invite All Participants" 722 | msgstr "Пригласить всех участников" 723 | 724 | #: templates/conference_details.html:96 725 | msgid "Phone number" 726 | msgstr "Номер телефона" 727 | 728 | #: templates/conference_details.html:99 templates/conference_details.html:166 729 | msgid "Invite" 730 | msgstr "Пригласить" 731 | 732 | #: templates/conference_details.html:103 733 | msgid "Mute All" 734 | msgstr "Выключить все микрофоны" 735 | 736 | #: templates/conference_details.html:104 737 | msgid "Unmute All" 738 | msgstr "Включить все микрофоны" 739 | 740 | #: templates/conference_details.html:105 741 | msgid "Start Recording" 742 | msgstr "Начать запись" 743 | 744 | #: templates/conference_details.html:106 745 | msgid "Stop Recording" 746 | msgstr "Остановить запись" 747 | 748 | #: templates/conference_details.html:108 749 | msgid "Unlock" 750 | msgstr "Разблокировать" 751 | 752 | #: templates/conference_details.html:110 753 | msgid "Lock" 754 | msgstr "Заблокировать" 755 | 756 | #: templates/conference_details.html:112 757 | msgid "Kick All" 758 | msgstr "Отключить всех" 759 | 760 | #: templates/conference_details.html:135 761 | msgid "Unmute" 762 | msgstr "Включить микрофон" 763 | 764 | #: templates/conference_details.html:139 765 | msgid "Mute" 766 | msgstr "Выключить микрофон" 767 | 768 | #: templates/conference_details.html:167 769 | msgid "Kick" 770 | msgstr "Отключить" 771 | 772 | #: templates/conference_details.html:226 773 | msgid "Conference Log" 774 | msgstr "Лог конференции" 775 | 776 | #: templates/conference_details.html:226 777 | msgid "Clear Log" 778 | msgstr "Очистить лог" 779 | 780 | #: templates/conference_edit.html:8 781 | msgid "You can manage participants in menu Participants." 782 | msgstr "Управляйте списками Участников конференции в меню Участники." 783 | 784 | #: templates/conference_schedule_list.html:5 785 | msgid "Install the Schedule" 786 | msgstr "Установить расписание" 787 | 788 | #: templates/contact_create.html:5 789 | msgid "You can also" 790 | msgstr "Вы можете также" 791 | 792 | #: templates/contact_create.html:5 793 | msgid "Import Contacts" 794 | msgstr "Импортировать контакты" 795 | 796 | #: templates/contact_import.html:5 797 | msgid "Import Contacts from a CSV file (phone, name):" 798 | msgstr "Импортировать контакты из CSV-файла (телефон, имя):" 799 | 800 | #: templates/my_master.html:13 801 | msgid "Logout" 802 | msgstr "Выйти" 803 | 804 | #: templates/my_master.html:17 805 | msgid "Login" 806 | msgstr "Войти" 807 | 808 | #: templates/my_master.html:22 809 | msgid "Language" 810 | msgstr "Язык" 811 | 812 | #: utils/validators.py:10 813 | msgid "Must be a number!" 814 | msgstr "Тип данных - число!" 815 | 816 | #: utils/validators.py:19 817 | #, python-format 818 | msgid "Participant with phone number %(num)s already there." 819 | msgstr "Участник с номером %(num)s уже добавлен." 820 | 821 | #: utils/validators.py:34 822 | #, python-format 823 | msgid "%(job)s is not a correct crontab entry." 824 | msgstr "%(job)s не является корректной записью планироващика." 825 | 826 | #~ msgid "Talking" 827 | #~ msgstr "Говорит" 828 | 829 | -------------------------------------------------------------------------------- /astconfman/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from os.path import dirname, join 4 | from crontab import CronTab 5 | from flask import request, render_template, Response, redirect, url_for 6 | from flask import Blueprint, flash, abort, jsonify 7 | from flask_admin import Admin, AdminIndexView, BaseView, expose 8 | from flask_admin.contrib.sqla.ajax import QueryAjaxModelLoader 9 | from flask_admin import helpers as admin_helpers 10 | from flask_admin.actions import action 11 | from flask_admin.contrib.sqla import ModelView, filters 12 | from flask_admin.contrib.fileadmin import FileAdmin 13 | from flask_admin.form import rules 14 | from flask_babelex import lazy_gettext as _, gettext 15 | from flask_security import current_user 16 | from flask_security.utils import encrypt_password 17 | from jinja2 import Markup 18 | from wtforms.fields import PasswordField 19 | from wtforms.validators import Required, ValidationError 20 | from models import Contact, Conference, ConferenceLog, Participant 21 | from models import ConferenceProfile, ParticipantProfile, ConferenceSchedule 22 | from utils.validators import is_number, is_participant_uniq, is_crontab_valid 23 | from app import app, db, security, sse_notify, User, Role 24 | from forms import ContactImportForm, ConferenceForm 25 | from asterisk import * 26 | from asterisk2.ami import AMIClient 27 | from asterisk2.ami import AutoReconnect 28 | from asterisk2.ami import EventListener 29 | 30 | talkers = [] 31 | 32 | 33 | class AuthBaseView(BaseView): 34 | def is_accessible(self): 35 | if not current_user.is_active or not current_user.is_authenticated: 36 | return False 37 | return True 38 | 39 | def _handle_view(self, name, **kwargs): 40 | if not self.is_accessible(): 41 | if current_user.is_authenticated: 42 | abort(403) 43 | else: 44 | return redirect(url_for('security.login', next=request.url)) 45 | 46 | 47 | class MyModelView(ModelView): 48 | def on_model_change(self, form, model, is_created): 49 | if is_created: 50 | model.user = current_user 51 | 52 | 53 | class UserModelView(ModelView): 54 | def is_accessible(self): 55 | return super(AuthBaseView, self).is_accessible() and current_user.has_role('user') or False 56 | 57 | def get_query(self): 58 | return super(UserModelView, self).get_query().filter_by(user = current_user) 59 | 60 | def get_query_count(self): 61 | return self.get_query().count() 62 | 63 | def get_one(self, id): 64 | return super(UserModelView, self).get_query().filter_by(user=current_user,id=id).first() 65 | 66 | 67 | def legend_formatter(view, context, model, name): 68 | """Formatter for legend columns for profiles""" 69 | glyph = ' ' 70 | legend = '' 71 | if isinstance(model, ParticipantProfile): 72 | if model.admin: 73 | legend += (glyph % (gettext('Admin'), 'text-color')) 74 | if model.marked: 75 | legend += (glyph % (gettext('Marked'), 'king')) 76 | if model.pin: 77 | legend += (glyph % (gettext('PIN is set'), 'lock')) 78 | if model.startmuted: 79 | legend += (glyph % (gettext('PIN is set'), 'volume-off')) 80 | if model.wait_marked: 81 | legend += (glyph % (gettext('Wait for marked user to join'), 82 | ' glyphicon-log-in')) 83 | if model.end_marked: 84 | legend += (glyph % (gettext('End when marked user leaves'), 85 | ' glyphicon-log-out')) 86 | 87 | elif isinstance(model, Conference): 88 | if model.is_public: 89 | legend += (glyph % (gettext('Guests (not from participant ' 90 | 'list) can join'), 'plus-sign')) 91 | else: 92 | legend += (glyph % (gettext('Only for participants ' 93 | 'specified'), 'ban-circle')) 94 | return Markup(legend) 95 | 96 | 97 | 98 | class ContactAdmin(MyModelView, AuthBaseView): 99 | column_list = ['phone', 'name', 'user'] 100 | form_columns = ['phone', 'name', 'user'] 101 | create_template = 'contact_create.html' 102 | column_searchable_list = ['phone', 'name'] 103 | column_filters = ['user'] 104 | page_size = 500 105 | form_args = { 106 | 'phone': dict(validators=[Required(), is_number]) 107 | } 108 | column_labels = { 109 | 'phone': _('Phone'), 110 | 'name': _('Name'), 111 | 'user': _('User') 112 | } 113 | 114 | @action('conference', _('Add to Conference')) 115 | def action_conference(self, ids): 116 | return render_template('action_conference.html', ids=ids, 117 | conferences=Conference.query.all(), 118 | profiles=ParticipantProfile.query.all()) 119 | 120 | @expose('/import', methods=['POST', 'GET']) 121 | def import_contacts(self): 122 | form = ContactImportForm() 123 | if request.method == 'GET': 124 | return self.render('contact_import.html', form=form) 125 | 126 | else: 127 | form = ContactImportForm() 128 | if form.validate_on_submit(): 129 | data = form.filename.data.readlines() 130 | imported = 0 131 | for line in data: 132 | line = line.split(',') 133 | c = Contact() 134 | c.phone = line[0] 135 | c.name = line[1].decode('utf-8') 136 | c.user = current_user 137 | db.session.add(c) 138 | imported += 1 139 | db.session.commit() 140 | flash(gettext('Imported %(num)s contacts.', num=imported)) 141 | return redirect(url_for('.index_view')) 142 | 143 | else: 144 | return self.render('contact_import.html', form=form) 145 | 146 | def is_accessible(self): 147 | return super(ContactAdmin, self).is_accessible() and current_user.has_role('admin') or False 148 | 149 | 150 | 151 | class ContactUser(UserModelView, ContactAdmin): 152 | column_list = ['phone', 'name'] 153 | form_columns = ['phone', 'name'] 154 | 155 | @action('conference', _('Add to Conference')) 156 | def action_conference(self, ids): 157 | return render_template('action_conference.html', ids=ids, 158 | conferences=Conference.query.filter_by( 159 | user=current_user), 160 | profiles=ParticipantProfile.query.all()) 161 | 162 | 163 | 164 | class ParticipantAdmin(MyModelView, AuthBaseView): 165 | column_searchable_list = ('phone', 'name') 166 | page_size = 500 167 | column_filters = ['conference', 'profile', 'user'] 168 | column_formatters = { 169 | 'legend': lambda v,c,m,n: legend_formatter(v,c,m,n) 170 | } 171 | column_list = form_columns = ['phone', 'name', 'is_invited', 'conference', 172 | 'profile', 'user'] 173 | form_args = { 174 | 'phone': dict(validators=[Required(), is_number]), 175 | 'conference': dict(validators=[Required()]), 176 | 'profile': dict(validators=[Required()]), 177 | } 178 | column_labels = { 179 | 'phone': _('Phone'), 180 | 'name': _('Name'), 181 | 'conference': _('Conference'), 182 | 'profile': _('Participant Profile'), 183 | 'is_invited': _('Is invited on Invite All?'), 184 | 'user': _('User'), 185 | } 186 | column_descriptions = { 187 | 'is_invited': _('When enabled this participant will be called on Invite All from Manage Conference menu.'), 188 | } 189 | 190 | def is_accessible(self): 191 | return super(AuthBaseView, self).is_accessible() and current_user.has_role('admin') or False 192 | 193 | 194 | class ConferenceQueryAjaxModelLoader(QueryAjaxModelLoader): 195 | def get_list(self, term, offset=0, limit=10): 196 | query = super(ConferenceQueryAjaxModelLoader, self).get_list(term, 197 | offset=offset, limit=limit) 198 | return [k for k in query if k.user==current_user] 199 | 200 | 201 | 202 | class ParticipantUser(UserModelView, ParticipantAdmin): 203 | column_filters = ['conference', 'profile'] 204 | column_list = form_columns = ['phone', 'name', 'is_invited', 'conference', 205 | 'profile'] 206 | form_ajax_refs = { 207 | 'conference': ConferenceQueryAjaxModelLoader('conference', db.session, Conference, fields=['name'], page_size=10) 208 | } 209 | 210 | 211 | class ConferenceAdmin(MyModelView, AuthBaseView): 212 | """ 213 | This is active conference started in a room. 214 | """ 215 | form_base_class = ConferenceForm 216 | can_view_details = True 217 | details_template = 'conference_details.html' 218 | edit_template = 'conference_edit.html' 219 | create_template = 'conference_create.html' 220 | 221 | column_list = ['number', 'name', 'is_public', 'is_locked', 222 | 'participant_count', 'invited_participant_count', 'user'] 223 | column_labels = { 224 | 'number': _('Conference Number'), 225 | 'name': _('Conference Name'), 226 | 'participant_count': _('Participants'), 227 | 'invited_participant_count': _('Invited Participants'), 228 | 'online_participant_count': _('Participants Online'), 229 | 'is_locked': _('Locked'), 230 | 'is_public': _('Public'), 231 | 'conference_profile': _('Conference Profile'), 232 | 'public_participant_profile': _('Public Participant Profile'), 233 | } 234 | 235 | form_create_rules = form_edit_rules = [ 236 | rules.FieldSet( 237 | ('number', 'name', 'conference_profile'), 238 | _('Basic Settings') 239 | ), 240 | rules.FieldSet( 241 | ('is_public', 'public_participant_profile', 'user'), 242 | _('Open Access') 243 | ), 244 | rules.FieldSet( 245 | (rules.Macro('conference_participants_link'),), 246 | _('Participants') 247 | ), 248 | ] 249 | 250 | column_formatters = { 251 | 'legend': lambda v,c,m,n: legend_formatter(v,c,m,n), 252 | } 253 | 254 | form_args = { 255 | 'number': dict(validators=[Required(), is_number]), 256 | 'name': dict(validators=[Required()]), 257 | 'conference_profile': dict(validators=[Required()]), 258 | 'public_participant_profile': dict(validators=[Required()]), 259 | } 260 | 261 | def is_accessible(self): 262 | return super(AuthBaseView, self).is_accessible() and current_user.has_role('admin') or False 263 | 264 | 265 | @expose('/details/') 266 | def details_view(self): 267 | conf = Conference.query.get_or_404(request.args.get('id', 0)) 268 | self._template_args['confbridge_participants'] = \ 269 | confbridge_list_participants(conf.number) 270 | self._template_args['confbridge'] = confbridge_get(conf.number) 271 | return super(ModelView, self).details_view() 272 | 273 | 274 | @expose('/contacts/', methods=['POST']) 275 | def add_contacts(self): 276 | if request.method == 'POST': 277 | if not request.form.get('conference') or not request.form.get( 278 | 'profile'): 279 | flash( 280 | 'You must select Conference and Profile') 281 | if current_user.has_role('admin'): 282 | return redirect(url_for('contact_admin.index_view')) 283 | else: 284 | return redirect(url_for('contact_user.index_view')) 285 | 286 | conference = Conference.query.filter_by( 287 | id=request.form['conference']).first_or_404() 288 | 289 | profile = ParticipantProfile.query.filter_by( 290 | id=request.form['profile']).first_or_404() 291 | 292 | contacts = Contact.query.filter( 293 | Contact.id.in_(request.form['ids'].split(','))) 294 | 295 | for c in contacts: 296 | if Participant.query.filter_by(phone=c.phone, 297 | conference=conference).first(): 298 | flash(gettext( 299 | '%(contact)s is already there.', contact=c)) 300 | continue 301 | p = Participant(phone=c.phone, name=c.name, user=current_user, 302 | profile=profile, conference=conference) 303 | flash(gettext( 304 | '%(contact)s added.', contact=c)) 305 | 306 | db.session.add(p) 307 | db.session.commit() 308 | 309 | return redirect(url_for('.edit_view', id=conference.id)) 310 | 311 | 312 | @expose('//invite_guest') 313 | def invite_guest(self, conf_id): 314 | conf = Conference.query.get_or_404(conf_id) 315 | phone = request.args.get('phone', None) 316 | if phone and phone.isdigit(): 317 | # Check if number in participant list 318 | participant = next((p for p in conf.participants if p.phone == phone), None) 319 | if participant: 320 | options = participant.profile.get_confbridge_options() 321 | else: 322 | options = conf.public_participant_profile.get_confbridge_options() 323 | try: 324 | originate(conf.number, phone, 325 | bridge_options=conf.conference_profile.get_confbridge_options(), 326 | user_options=options) 327 | flash(gettext('Number %(phone)s is called for conference.', phone=phone)) 328 | except Exception as e: 329 | flash(gettext('Failed to call number %(phone)s: %(error)s', phone=phone, error=str(e)), 'error') 330 | else: 331 | flash(gettext('Invalid phone number: %(phone)s', phone=phone), 'error') 332 | time.sleep(1) 333 | return redirect(url_for('.details_view', id=conf_id)) 334 | 335 | 336 | @expose('//invite_participants') 337 | def invite_participants(self, conf_id): 338 | conf = Conference.query.get_or_404(conf_id) 339 | conf.invite_participants() 340 | flash(gettext( 341 | 'All the participants where invited to the conference')) 342 | time.sleep(1) 343 | return redirect(url_for('.details_view', id=conf.id)) 344 | 345 | 346 | 347 | @expose('/kick/') 348 | @expose('/kick//channel/') 349 | def kick(self, conf_id, channel=None): 350 | conf = Conference.query.filter_by(id=conf_id).first_or_404() 351 | if channel: 352 | confbridge_kick(conf.number, channel) 353 | msg = gettext('Channel %(channel)s is kicked.', channel=channel) 354 | flash(msg) 355 | conf.log(msg) 356 | else: 357 | confbridge_kick_all(conf.number) 358 | msg = gettext('All participants have been kicked from the conference.') 359 | conf.log(msg) 360 | flash(msg) 361 | sse_notify(conf.id, 'update_participants') 362 | time.sleep(1) 363 | return redirect(url_for('.details_view', id=conf.id)) 364 | 365 | 366 | @expose('/mute/') 367 | @expose('/mute//channel/') 368 | def mute(self, conf_id, channel=None): 369 | conf = Conference.query.get_or_404(conf_id) 370 | if channel: 371 | confbridge_mute(conf.number, channel) 372 | msg = gettext('Participant %(channel)s muted.', channel=channel) 373 | flash(msg) 374 | conf.log(msg) 375 | else: 376 | # Mute all 377 | for p in confbridge_list_participants(conf.number): 378 | confbridge_mute(conf.number, p['channel']) 379 | msg = gettext('Conference muted.') 380 | flash(msg) 381 | conf.log(msg) 382 | sse_notify(conf.id, 'update_participants') 383 | time.sleep(1) 384 | return redirect(url_for('.details_view', id=conf_id)) 385 | 386 | 387 | @expose('/unmute/') 388 | @expose('/unmute//channel/') 389 | def unmute(self, conf_id, channel=None): 390 | conf = Conference.query.get_or_404(conf_id) 391 | if channel: 392 | confbridge_unmute(conf.number, channel) 393 | msg = gettext('Participant %(channel)s unmuted.', channel=channel) 394 | flash(msg) 395 | conf.log(msg) 396 | else: 397 | # Mute all 398 | for p in confbridge_list_participants(conf.number): 399 | confbridge_unmute(conf.number, p['channel']) 400 | msg = gettext('Conference unmuted.') 401 | flash(msg) 402 | conf.log(msg) 403 | sse_notify(conf.id, 'update_participants') 404 | time.sleep(1) 405 | return redirect(url_for('.details_view', id=conf_id)) 406 | 407 | 408 | @expose('//record_start') 409 | def record_start(self, conf_id): 410 | conf = Conference.query.get_or_404(conf_id) 411 | confbridge_record_start(conf.number) 412 | msg = gettext('The conference recording has been started.') 413 | flash(msg) 414 | conf.log(msg) 415 | return redirect(url_for('.details_view', id=conf_id)) 416 | 417 | 418 | @expose('//record_stop') 419 | def record_stop(self, conf_id): 420 | conf = Conference.query.get_or_404(conf_id) 421 | confbridge_record_stop(conf.number) 422 | msg = gettext('The conference recording has been stopped.') 423 | flash(msg) 424 | conf.log(msg) 425 | return redirect(url_for('.details_view', id=conf_id)) 426 | 427 | 428 | @expose('//lock') 429 | def lock(self, conf_id): 430 | conf = Conference.query.get_or_404(conf_id) 431 | confbridge_lock(conf.number) 432 | msg = gettext('The conference has been locked.') 433 | flash(msg) 434 | conf.log(msg) 435 | sse_notify(conf.id, 'update_participants') 436 | time.sleep(1) 437 | return redirect(url_for('.details_view', id=conf_id)) 438 | 439 | 440 | @expose('//unlock') 441 | def unlock(self, conf_id): 442 | conf = Conference.query.get_or_404(conf_id) 443 | confbridge_unlock(conf.number) 444 | msg = gettext('The conference has been unlocked.') 445 | flash(msg) 446 | conf.log(msg) 447 | sse_notify(conf.id, 'update_participants') 448 | time.sleep(1) 449 | return redirect(url_for('.details_view', id=conf_id)) 450 | 451 | 452 | @expose('//clear_log') 453 | def clear_log(self, conf_id): 454 | logs = ConferenceLog.query.filter_by(conference_id=conf_id) 455 | for log in logs: 456 | db.session.delete(log) 457 | db.session.commit() 458 | return redirect(url_for('.details_view', id=conf_id)) 459 | 460 | 461 | class ConferenceUser(UserModelView, ConferenceAdmin): 462 | column_list = ['number', 'name', 'is_public', 'is_locked', 463 | 'participant_count', 'invited_participant_count'] 464 | form_create_rules = form_edit_rules = [ 465 | rules.FieldSet( 466 | ('number', 'name', 'conference_profile'), 467 | _('Basic Settings') 468 | ), 469 | rules.FieldSet( 470 | ('is_public', 'public_participant_profile'), 471 | _('Open Access') 472 | ), 473 | rules.FieldSet( 474 | (rules.Macro('conference_participants_link'),), 475 | _('Participants') 476 | ), 477 | ] 478 | 479 | @expose('/details/') 480 | def details_view(self): 481 | conf = Conference.query.get_or_404(request.args.get('id', 0)) 482 | self._template_args['confbridge_participants'] = \ 483 | confbridge_list_participants(conf.number) 484 | self._template_args['confbridge'] = confbridge_get(conf.number) 485 | return super(ModelView, self).details_view() 486 | 487 | 488 | 489 | class ConferenceScheduleAdmin(MyModelView, AuthBaseView): 490 | list_template = 'conference_schedule_list.html' 491 | column_list = form_columns = ['conference', 'entry', 'user'] 492 | column_labels = { 493 | 'entry': _('Entry'), 494 | 'conference': _('Conference'), 495 | } 496 | column_descriptions = { 497 | 'entry': _("""Format: Minute Hour Day-of-Month Month Day-of-Week. Examples:
498 | 30 10 * * 1,2,3,4,5 - Every workday at 10:30 a.m.
499 | 0 10 1 * * - Every 1-st day of every month at 10:00 a.m.
500 | See Linux Crontab: 15 Awesome Cron Job Examples -
501 | http://www.thegeekstuff.com/2009/06/15-practical-crontab-examples/ 502 | """), 503 | } 504 | form_args = { 505 | 'conference': {'validators': [Required()]}, 506 | 'entry': {'validators': [Required(), is_crontab_valid]} 507 | } 508 | 509 | def is_accessible(self): 510 | return super(AuthBaseView, self).is_accessible() and current_user.has_role('admin') or False 511 | 512 | 513 | @expose('/install') 514 | def install(self): 515 | flask_cron = CronTab(user=True) 516 | flask_cron.remove_all() 517 | for conference_schedule in ConferenceSchedule.query.all(): 518 | job = flask_cron.new(command=join(dirname(__file__), 519 | 'cron_job.sh %s' % conference_schedule.conference.number), 520 | comment='%s' % conference_schedule.conference) 521 | job.setall(conference_schedule.entry) 522 | flask_cron.write_to_user() 523 | flash(gettext('Crontab has been installed successfully.')) 524 | return redirect(url_for('.index_view')) 525 | 526 | 527 | class ConferenceScheduleUser(UserModelView, ConferenceScheduleAdmin): 528 | column_list = form_columns = ['conference', 'entry'] 529 | form_ajax_refs = { 530 | 'conference': ConferenceQueryAjaxModelLoader('conference', db.session, Conference, fields=['name'], page_size=10) 531 | } 532 | 533 | 534 | class RecordingAdmin(FileAdmin, AuthBaseView): 535 | can_upload = False 536 | can_download = True 537 | can_delete = True 538 | can_mkdir = False 539 | can_rename = True 540 | can_mkdir = False 541 | 542 | def is_accessible(self): 543 | return super(AuthBaseView, self).is_accessible() and current_user.has_role('admin') or False 544 | 545 | 546 | 547 | class ConferenceProfileAdmin(ModelView, AuthBaseView): 548 | column_list = [ 549 | 'name', 'max_members', 'record_conference', 550 | 'internal_sample_rate', 'mixing_interval', 'video_mode' 551 | ] 552 | column_labels = { 553 | 'name': _('Profile Name'), 554 | } 555 | column_descriptions = { 556 | 'max_members': _("""Limits the number of participants for a single conference to a specific number. By default, conferences have no participant limit. After the limit is reached, the conference will be locked until someone leaves. Admin-level users are exempt from this limit and will still be able to join otherwise-locked, because of limit, conferences."""), 557 | 'record_conference': _("""Records the conference call starting when the first user enters the room, and ending when the last user exits the room. The default recorded filename is 'confbridge--.wav' and the default format is 8kHz signed linear. By default, this option is disabled. This file will be located in the configured monitoring directory as set in conf"""), 558 | 'internal_sample_rate': _("""Sets the internal native sample rate at which to mix the conference. The "auto" option allows Asterisk to adjust the sample rate to the best quality / performance based on the participant makeup. Numbered values lock the rate to the specified numerical rate. If a defined number does not match an internal sampling rate supported by Asterisk, the nearest sampling rate will be used instead."""), 559 | 'mixing_interval': _("""Sets, in milliseconds, the internal mixing interval. By default, the mixing interval of a bridge is 20ms. This setting reflects how "tight" or "loose" the mixing will be for the conference. Lower intervals provide a "tighter" sound with less delay in the bridge and consume more system resources. Higher intervals provide a "looser" sound with more delay in the bridge and consume less resources"""), 560 | 'video_mode': _("""Configured video (as opposed to audio) distribution method for conference participants. Participants must use the same video codec. Confbridge does not provide MCU functionality. It does not transcode, scale, transrate, or otherwise manipulate the video. Options are "none," where no video source is set by default and a video source may be later set via AMI or DTMF actions; "follow_talker," where video distrubtion follows whomever is talking and providing video; "last_marked," where the last marked user with video capabilities to join the conference will be the single video source distributed to all other participants - when the current video source leaves, the marked user previous to the last-joined will be used as the video source; and "first-marked," where the first marked user with video capabilities to join the conference will be the single video source distributed to all other participants - when the current video source leaves, the marked user that joined next will be used as the video source. Use of video in conjunction with the jitterbuffer results in the audio being slightly out of sync with the video - because the jitterbuffer only operates on the audio stream, not the video stream. Jitterbuffer should be disabled when video is used.""") 561 | } 562 | form_choices = { 563 | 'internal_sample_rate': [('auto','auto'), ('8000', '8000'), 564 | ('12000', '12000'), ('16000', '16000'), 565 | ('24000', '24000'), ('32000', '32000'), 566 | ('44100', '44100'), ('48000', '48000'), 567 | ('96000', '96000'), ('192000', '192000')], 568 | 'mixing_interval': [('10', '10'), ('20', '20'), ('40', '40'), ('80','80')], 569 | 'video_mode': [('none', 'none'), ('follow_talker', 'follow_talker'), 570 | ('last_marked', 'last_marked'), 571 | ('first_marked', 'first_marked')], 572 | } 573 | can_view_details = True 574 | form_args = { 575 | 'name': {'validators': [Required()]}, 576 | 'mixing_interval': {'validators': [Required()]}, 577 | } 578 | 579 | def is_accessible(self): 580 | return super(AuthBaseView, self).is_accessible() and current_user.has_role('admin') or False 581 | 582 | 583 | 584 | class ParticipantProfileAdmin(ModelView, AuthBaseView): 585 | """Class that repesents confbridge user profiles""" 586 | column_list = ['name', 'legend'] 587 | column_formatters = { 588 | 'legend': lambda v,c,m,n: legend_formatter(v,c,m,n), 589 | } 590 | can_view_details = True 591 | form_args = { 592 | 'name': {'validators': [Required()]}, 593 | 'music_on_hold_class': {'validators': [Required()]} 594 | } 595 | form_create_rules = form_edit_rules = [ 596 | 'name', 597 | rules.FieldSet( 598 | ( 599 | 'admin', 600 | 'marked', 601 | 'pin', 602 | 'startmuted', 603 | 'quiet', 604 | 'wait_marked', 605 | 'end_marked', 606 | 'music_on_hold_when_empty', 607 | 'music_on_hold_class', 608 | ), 609 | _('Basic')), 610 | rules.FieldSet( 611 | ( 612 | 'announce_user_count', 613 | 'announce_user_count_all', 614 | 'announce_only_user', 615 | 'announcement', 616 | 'announce_join_leave', 617 | ), 618 | _('Announcements') 619 | ), 620 | rules.FieldSet( 621 | ( 622 | 'dsp_drop_silence', 623 | 'dsp_talking_threshold', 624 | 'dsp_silence_threshold', 625 | 'talk_detection_events', 626 | 'denoise', 627 | 'jitterbuffer', 628 | 'dtmf_passthrough', 629 | ), 630 | _('Voice Processing') 631 | ) 632 | ] 633 | column_labels = { 634 | 'name': _('Profile Name'), 635 | 'legend': _('Legend'), 636 | } 637 | """ # I'don want to tranlate asterisk profile setting names 638 | 'admin': _('Admin'), 639 | 'marked': _('Marked'), 640 | 'pin': _('PIN'), 641 | 'startmuted': _('Start Muted'), 642 | 'quiet': _('Quiet'), 643 | 'wait_marked': _('Wait Marked'), 644 | 'end_marked': _('End Marked'), 645 | 'music_on_hold_when_empty': _('Music On Hold When Empty'), 646 | 'music_on_hold_class': _(''), 647 | """ 648 | column_descriptions = { 649 | 'admin': _('Sets if the user is an Admin or not. By default, no.'), 650 | 'marked': _('Sets if the user is Marked or not. By default, no.'), 651 | 'startmuted': _('Sets if the user should start out muted. By default, no.'), 652 | 'pin': _('Sets if the user must enter a PIN before joining the conference. The user will be prompted for the PIN.'), 653 | 'startmuted': _('Sets if the user should start out muted. By default, no.'), 654 | 'quiet': _('When set, enter/leave prompts and user introductions are not played. By default, no.'), 655 | 'wait_marked': _('Sets if the user must wait for another marked user to enter before joining the conference. By default, no.'), 656 | 'end_marked': _('If enabled, every user with this option in their profile will be removed from the conference when the last marked user exists the conference.'), 657 | 'dtmf_passthrough': _('Whether or not DTMF received from users should pass through the conference to other users. By default, no.'), 658 | 'music_on_hold_when_empty': _('Sets whether music on hold should be played when only one person is in the conference or when the user is waiting on a marked user to enter the conference. By default, off.'), 659 | 'music_on_hold_class': _('Sets the music on hold class to use for music on hold.'), 660 | 'announce_user_count': _('Sets if the number of users in the conference should be announced to the caller. By default, no.'), 661 | 'announce_user_count_all': _('Choices: yes, no, integer. Sets if the number of users should be announced to all other users in the conference when someone joins. When set to a number, the announcement will only occur once the user count is above the specified number'), 662 | 'announce_only_user': _('Sets if the only user announcement should be played when someone enters an empty conference. By default, yes.'), 663 | 'announcement': _('If set, the sound file specified by filename will be played to the user, and only the user, upon joining the conference bridge.'), 664 | 'announce_join_leave': _('When enabled, this option prompts the user for their name when entering the conference. After the name is recorded, it will be played as the user enters and exists the conference. By default, no.'), 665 | 'dsp_drop_silence': _('Drops what Asterisk detects as silence from entering into the bridge. Enabling this option will drastically improve performance and help remove the buildup of background noise from the conference. This option is highly recommended for large conferences, due to its performance improvements.'), 666 | 'dsp_talking_threshold': _("""The time, in milliseconds, by default 160, of sound above what the DSP has established as base-line silence for a user, before that user is considered to be talking. This value affects several options: 667 | Audio is only mixed out of a user's incoming audio stream if talking is detected. If this value is set too loose, the user will hear themselves briefly each time they begin talking until the DSP has time to establish that they are in fact talking. 668 | When talker detection AMI events are enabled, this value determines when talking has begun, which causes AMI events to fire. If this value is set too tight, AMI events may be falsely triggered by variants in the background noise of the caller. 669 | The drop_silence option depends on this value to determine when the user's audio should be mixed into the bridge after periods of silence. If this value is too loose, the beginning of a user's speech will get cut off as they transition from silence to talking."""), 670 | 'dsp_silence_threshold': _("""The time, in milliseconds, by default 2500, of sound falling within what the DSP has established as the baseline silence, before a user is considered to be silent. The best way to approach this option is to set it slightly above the maximum amount of milliseconds of silence a user may generate during natural speech. This value affects several operations: 671 | When talker detection AMI events are enabled, this value determines when the user has stopped talking after a period of talking. If this value is set too low, AMI events indicating that the user has stopped talking may get faslely sent out when the user briefly pauses during mid sentence. 672 | The drop_silence option depends on this value to determine when the user's audio should begin to be dropped from the bridge, after the user stops talking. If this value is set too low, the user's audio stream may sound choppy to other participants."""), 673 | 'talk_detection_events': _('Sets whether or not notifications of when a user begins and ends talking should be sent out as events over AMI. By default, no.'), 674 | 'denoise': _('Whether or not a noise reduction filter should be applied to the audio before mixing. By default, off. This requires codec_speex to be built and installed. Do not confuse this option with drop_silence. denoise is useful if there is a lot of background noise for a user, as it attempts to remove the noise while still preserving the speech. This option does not remove silence from being mixed into the conference and does come at the cost of a slight performance hit.'), 675 | 'jitterbuffer': _("Whether or not to place a jitter buffer on the caller's audio stream before any audio mixing is performed. This option is highly recommended, but will add a slight delay to the audio and will incur a slight performance penalty. This option makes use of the JITTERBUFFER dialplan function's default adaptive jitter buffer. For a more fine-tuned jitter buffer, disable this option and use the JITTERBUFFER dialplan function on the calling channel, before it enters the ConfBridge application."), 676 | 677 | } 678 | 679 | def is_accessible(self): 680 | return super(AuthBaseView, self).is_accessible() and current_user.has_role('admin') or False 681 | 682 | 683 | 684 | class UserAdmin(ModelView, AuthBaseView): 685 | column_exclude_list = ('password',) 686 | form_excluded_columns = ('password',) 687 | column_auto_select_related = True 688 | 689 | def is_accessible(self): 690 | return super(UserAdmin, self).is_accessible() and current_user.has_role('admin') or False 691 | 692 | 693 | def scaffold_form(self): 694 | form_class = super(UserAdmin, self).scaffold_form() 695 | form_class.password2 = PasswordField(gettext('New Password')) 696 | return form_class 697 | 698 | def on_model_change(self, form, model, is_created): 699 | if len(model.password2): 700 | model.password = encrypt_password(model.password2) 701 | 702 | 703 | class RoleAdmin(ModelView, AuthBaseView): 704 | def is_accessible(self): 705 | return super(RoleAdmin, self).is_accessible() and current_user.has_role('admin') or False 706 | 707 | class MyAdminIndexView(AdminIndexView): 708 | pass 709 | 710 | admin = Admin( 711 | app, 712 | name=app.config['BRAND_NAV'], 713 | index_view=MyAdminIndexView( 714 | template='admin/index.html', 715 | url='/' 716 | ), 717 | base_template='my_master.html', 718 | template_mode='bootstrap3', 719 | category_icon_classes={ 720 | 'Main': 'glyphicon glyphicon-wrench', 721 | 'Profiles': 'glyphicon glyphicon-wrench', 722 | 'Users': 'glyphicon glyphicon-user', 723 | 'Participants': 'glyphicon glyphicon-book', 724 | 'Conferences': 'glyphicon glyphicon-bullhorn', 725 | } 726 | ) 727 | 728 | @security.context_processor 729 | def security_context_processor(): 730 | return dict( 731 | admin_base_template=admin.base_template, 732 | admin_view=admin.index_view, 733 | h=admin_helpers, 734 | ) 735 | 736 | 737 | # This is a dict with views that will be added according to settings 738 | admin_views = { 739 | 'conferences_admin': ConferenceAdmin( 740 | Conference, 741 | db.session, 742 | endpoint='conference_admin', 743 | url='/admin/conference', 744 | category=_('Conferences'), 745 | name=_('Conferences'), 746 | menu_icon_type='glyph', 747 | menu_icon_value='glyphicon-bullhorn' 748 | ), 749 | 'conferences_user': ConferenceUser( 750 | Conference, 751 | db.session, 752 | endpoint='conference_user', 753 | url='/user/conference', 754 | category=_('Conferences'), 755 | name=_('Conferences'), 756 | menu_icon_type='glyph', 757 | menu_icon_value='glyphicon-bullhorn' 758 | ), 759 | 'schedule_admin': ConferenceScheduleAdmin( 760 | ConferenceSchedule, 761 | db.session, 762 | endpoint='conference_schedule_admin', 763 | url='/admin/schedule', 764 | category=_('Conferences'), 765 | name=_('Plan'), 766 | menu_icon_type='glyph', 767 | menu_icon_value='glyphicon-calendar', 768 | ), 769 | 'schedule_user': ConferenceScheduleUser( 770 | ConferenceSchedule, 771 | db.session, 772 | endpoint='conference_schedule_user', 773 | url='/user/schedule', 774 | category=_('Conferences'), 775 | name=_('Plan'), 776 | menu_icon_type='glyph', 777 | menu_icon_value='glyphicon-calendar', 778 | ), 779 | 'participants_admin': ParticipantAdmin( 780 | Participant, 781 | db.session, 782 | endpoint='participant_admin', 783 | url='/admin/participants', 784 | name=_('Participants'), 785 | category=_('Participants'), 786 | menu_icon_type='glyph', 787 | menu_icon_value='glyphicon-user' 788 | ), 789 | 'participants_user': ParticipantUser( 790 | Participant, 791 | db.session, 792 | endpoint='participant_user', 793 | url='/user/participants', 794 | name=_('Participants'), 795 | category=_('Participants'), 796 | menu_icon_type='glyph', 797 | menu_icon_value='glyphicon-user' 798 | ), 799 | 'contacts_admin': ContactAdmin( 800 | Contact, 801 | db.session, 802 | endpoint='contact_admin', 803 | url='/admin/contacts', 804 | name=_('Contacts'), 805 | category=_('Participants'), 806 | menu_icon_type='glyph', 807 | menu_icon_value='glyphicon-book' 808 | ), 809 | 'contacts_user': ContactUser( 810 | Contact, 811 | db.session, 812 | endpoint='contact_user', 813 | url='/user/contacts', 814 | name=_('Contacts'), 815 | category=_('Participants'), 816 | menu_icon_type='glyph', 817 | menu_icon_value='glyphicon-book' 818 | ), 819 | 'recordings': RecordingAdmin( 820 | app.config['ASTERISK_MONITOR_DIR'], 821 | '/static/recording/', 822 | endpoint='recording', 823 | name=_('Recordings'), 824 | menu_icon_type='glyph', 825 | menu_icon_value='glyphicon-hdd' 826 | ), 827 | 'participant_profiles': ParticipantProfileAdmin( 828 | ParticipantProfile, 829 | db.session, 830 | category=_('Profiles'), 831 | endpoint='participant_profile', 832 | url='/profile/participant', 833 | name=_('Participant'), 834 | menu_icon_type='glyph', 835 | menu_icon_value='glyphicon-user' 836 | ), 837 | 'conference_profiles': ConferenceProfileAdmin( 838 | ConferenceProfile, 839 | db.session, 840 | category=_('Profiles'), 841 | endpoint='room_profile', 842 | url='/profile/room', 843 | name=_('Conference'), 844 | menu_icon_type='glyph', 845 | menu_icon_value='glyphicon-bullhorn', 846 | ), 847 | 'users': UserAdmin( 848 | User, db.session, category=_('Users'), 849 | endpoint='users', 850 | url='/user', 851 | name=_('Users'), 852 | menu_icon_type='glyph', 853 | menu_icon_value='glyphicon-user' 854 | ), 855 | 'roles': RoleAdmin( 856 | Role, db.session, category=_('Users'), 857 | endpoint='roles', 858 | url='/role', 859 | name=_('Roles'), 860 | menu_icon_type='glyph', 861 | menu_icon_value='glyphicon-queen' 862 | ), 863 | 864 | } 865 | 866 | # Now add all views 867 | for v in admin_views.keys(): 868 | if v not in app.config['DISABLED_TABS']: 869 | admin.add_view(admin_views[v]) 870 | 871 | 872 | 873 | ### ASTERISK VIEWS 874 | asterisk = Blueprint('asterisk', __name__) 875 | 876 | def asterisk_is_authenticated(): 877 | return request.remote_addr == app.config['ASTERISK_IPADDR'] 878 | 879 | 880 | @asterisk.route('/invite_all//') 881 | def invite_all(conf_number, callerid): 882 | if not asterisk_is_authenticated(): 883 | return 'NOTAUTH' 884 | conf = Conference.query.filter_by(number=conf_number).first() 885 | if not conf: 886 | return 'NOCONF' 887 | participant = Participant.query.filter_by( 888 | conference=conf, phone=callerid).first() 889 | if not participant or not participant.profile.admin: 890 | return 'NOTALLOWED' 891 | online_participants = [ 892 | k['callerid'] for k in confbridge_list_participants( 893 | conf.number)] 894 | gen = ( 895 | p for p in conf.participants if p.phone not in online_participants) 896 | for p in gen: 897 | originate(conf.number, p.phone, name=p.name, 898 | bridge_options=conf.conference_profile.get_confbridge_options(), 899 | user_options=p.profile.get_confbridge_options()) 900 | return 'OK' 901 | 902 | 903 | @asterisk.route('/checkconf//') 904 | def check(conf_number, callerid): 905 | if not asterisk_is_authenticated(): 906 | return 'NOTAUTH' 907 | conf = Conference.query.filter_by(number=conf_number).first() 908 | 909 | if not conf: 910 | return 'NOCONF' 911 | 912 | elif callerid not in [ 913 | k.phone for k in conf.participants] and not conf.is_public: 914 | message = gettext('Attempt to enter non-public conference from %(phone)s.', 915 | phone=callerid) 916 | conf.log(message) 917 | return 'NOTPUBLIC' 918 | 919 | else: 920 | return 'OK' 921 | 922 | 923 | @asterisk.route('/confprofile/') 924 | def conf_profile(conf_number): 925 | if not asterisk_is_authenticated(): 926 | return 'NOTAUTH' 927 | conf = Conference.query.filter_by(number=conf_number).first() 928 | if not conf: 929 | return 'NOCONF' 930 | return ','.join(conf.conference_profile.get_confbridge_options()) 931 | 932 | 933 | @asterisk.route('/userprofile//') 934 | def user_profile(conf_number, callerid): 935 | if not asterisk_is_authenticated(): 936 | return 'NOTAUTH' 937 | conf = Conference.query.filter_by(number=conf_number).first() 938 | if not conf: 939 | return 'NOCONF' 940 | participant = Participant.query.filter_by(conference=conf, 941 | phone=callerid).first() 942 | if participant: 943 | # Return participant profile 944 | return ','.join(participant.profile.get_confbridge_options()) 945 | else: 946 | # Return public profile 947 | return ','.join( 948 | conf.public_participant_profile.get_confbridge_options()) 949 | 950 | 951 | @asterisk.route('/dial_status///') 952 | def dial_status(conf_number, callerid, status): 953 | if not asterisk_is_authenticated(): 954 | return 'NOTAUTH' 955 | message = gettext('Could not invite number %(num)s: %(status)s', num=callerid, 956 | status=status.capitalize()) 957 | conference = Conference.query.filter_by(number=conf_number).first_or_404() 958 | conference.log(message) 959 | return 'OK' 960 | 961 | 962 | @asterisk.route('/enter_conference//') 963 | def enter_conference(conf_number, callerid): 964 | if not asterisk_is_authenticated(): 965 | return 'NOTAUTH' 966 | message = gettext('Number %(num)s has entered the conference.', num=callerid) 967 | conference = Conference.query.filter_by(number=conf_number).first_or_404() 968 | conference.log(message) 969 | sse_notify(conference.id, 'update_participants') 970 | return 'OK' 971 | 972 | @asterisk.route('/leave_conference//') 973 | def leave_conference(conf_number, callerid): 974 | if not asterisk_is_authenticated(): 975 | return 'NOTAUTH' 976 | message = gettext('Number %(num)s has left the conference.', num=callerid) 977 | conference = Conference.query.filter_by(number=conf_number).first_or_404() 978 | conference.log(message) 979 | sse_notify(conference.id, 'update_participants') 980 | for num in talkers: 981 | if ( num == callerid ): 982 | talkers.remove(callerid) 983 | return 'OK' 984 | 985 | 986 | @asterisk.route('/unmute_request//') 987 | def unmute_request(conf_number, callerid): 988 | if not asterisk_is_authenticated(): 989 | return 'NOTAUTH' 990 | message = gettext('Unmute request from number %(num)s.', num=callerid) 991 | conference = Conference.query.filter_by(number=conf_number).first_or_404() 992 | conference.log(message) 993 | sse_notify(conference.id, 'unmute_request', callerid) 994 | return 'OK' 995 | 996 | @asterisk.route('/get_talkers_on//') 997 | def update_talkers_on(conf_number,callerid): 998 | message = gettext('Number %(num)s is talking.', num=callerid) 999 | conference = Conference.query.filter_by(number=conf_number).first_or_404() 1000 | conference.log(message) 1001 | sse_notify(conference.id, 'update_participants') 1002 | return 'OK' 1003 | 1004 | @asterisk.route('/get_talkers_off//') 1005 | def update_talkers_off(conf_number,callerid): 1006 | message = gettext('Number %(num)s is fell silent.', num=callerid) 1007 | conference = Conference.query.filter_by(number=conf_number).first_or_404() 1008 | conference.log(message) 1009 | sse_notify(conference.id, 'update_participants') 1010 | return 'OK' 1011 | 1012 | client = AMIClient(address='127.0.0.1',port=5038) 1013 | client.login(username=app.config['AMI_USER'],secret=app.config['AMI_PASSWORD']) 1014 | AutoReconnect(client) 1015 | 1016 | def event_listener_talk(event,**kwargs): 1017 | txt = event.keys['CallerIDNum'] 1018 | if str(txt).isdigit(): 1019 | talkers.append(txt) 1020 | os.system(gettext('wget -O - --no-proxy http://localhost:5000/asterisk/get_talkers_on/%(conf)s/%(num)s 2>/dev/null', conf=event.keys['Conference'], num=txt)) 1021 | 1022 | client.add_event_listener( 1023 | on_event=event_listener_talk, 1024 | white_list='ConfbridgeTalking', 1025 | TalkingStatus='on', 1026 | ) 1027 | 1028 | def event_listener_stoptalk(event,**kwargs): 1029 | txt = event.keys['CallerIDNum'] 1030 | if str(txt).isdigit(): 1031 | talkers.remove(txt) 1032 | os.system(gettext('wget -O - --no-proxy http://localhost:5000/asterisk/get_talkers_off/%(conf)s/%(num)s 2>/dev/null', conf=event.keys['Conference'], num=txt)) 1033 | 1034 | client.add_event_listener( 1035 | on_event=event_listener_stoptalk, 1036 | white_list='ConfbridgeTalking', 1037 | TalkingStatus='off', 1038 | ) 1039 | 1040 | @asterisk.route('/online_participants.json/') 1041 | def online_participants_json(conf_number): 1042 | ret = [] 1043 | ret2 = confbridge_list_participants(conf_number) 1044 | conf = Conference.query.filter_by(number=conf_number).first() 1045 | if not conf: 1046 | return 'NOCONF' 1047 | online_participants = [ 1048 | k['callerid'] for k in ret2] 1049 | partici = [ 1050 | k.phone for k in conf.participants] 1051 | 1052 | for p in conf.participants: 1053 | talking_gl = False 1054 | if (p.phone in online_participants): 1055 | online = True 1056 | for i in ret2: 1057 | for num in talkers: 1058 | if ( num == p.phone ): 1059 | talking_gl = True 1060 | if (i['callerid'] == p.phone): 1061 | flag = i['flags'] 1062 | channel = i['channel'] 1063 | else: 1064 | online = False 1065 | flag = '' 1066 | channel = '' 1067 | ret.append({ 1068 | 'id': p.id, 1069 | 'name': p.name, 1070 | 'phone': p.phone, 1071 | 'talking_gl': talking_gl, 1072 | 'callerid': p.phone, 1073 | 'is_invited': p.is_invited, 1074 | 'flags': flag, 1075 | 'channel': channel, 1076 | 'is_online': online 1077 | } 1078 | ) 1079 | 1080 | for i in ret2: 1081 | phone = i['callerid'] 1082 | talking_gl = False 1083 | if (i['callerid'] not in partici): 1084 | contac = Contact.query.filter_by(phone=i['callerid']).first() 1085 | if ( contac ): 1086 | name = contac.name 1087 | for num in talkers: 1088 | if ( num == i['callerid'] ): 1089 | talking_gl = True 1090 | 1091 | else: 1092 | name='' 1093 | ret.append ({ 1094 | 'id': '', 1095 | 'name': name, 1096 | 'phone': phone, 1097 | 'talking_gl': talking_gl, 1098 | 'callerid': phone, 1099 | 'is_invited': False, 1100 | 'flags': i['flags'], 1101 | 'channel': i['channel'], 1102 | 'is_online': True 1103 | } 1104 | ) 1105 | return Response(response=json.dumps(ret), 1106 | status=200, mimetype='application/json') 1107 | 1108 | 1109 | 1110 | --------------------------------------------------------------------------------