Hugo 4 gadi atpakaļ
revīzija
f3e361e2bf
83 mainītis faili ar 10285 papildinājumiem un 0 dzēšanām
  1. 3 0
      .bowerrc
  2. 34 0
      .gitignore
  3. 29 0
      LICENSE.md
  4. 233 0
      README.md
  5. 92 0
      Vagrantfile
  6. 31 0
      assets/AppAsset.php
  7. 27 0
      codeception.yml
  8. 867 0
      commands/CopyController.php
  9. 151 0
      commands/SyncCompleteController.php
  10. 360 0
      commands/SyncController.php
  11. 170 0
      components/data/Respuesta.php
  12. 45 0
      components/rest/JsonController.php
  13. 19 0
      components/rest/Serializer.php
  14. 71 0
      composer.json
  15. 5528 0
      composer.lock
  16. 50 0
      config/console.php
  17. 12 0
      config/db.php
  18. 12 0
      config/dbr.php
  19. 7 0
      config/params.php
  20. 42 0
      config/test.php
  21. 6 0
      config/test_db.php
  22. 78 0
      config/web.php
  23. 112 0
      controllers/DescargaController.php
  24. 51 0
      controllers/EstacionController.php
  25. 29 0
      controllers/InicioController.php
  26. 33 0
      controllers/LoginController.php
  27. 59 0
      controllers/PorPcController.php
  28. 67 0
      controllers/SiteController.php
  29. 9 0
      docker-compose.yml
  30. 22 0
      mail/layouts/html.php
  31. 41 0
      migrations/m211215_012217_inicio.php
  32. 62 0
      models/Descarga.php
  33. 65 0
      models/Login.php
  34. 90 0
      models/Usuario.php
  35. 162 0
      requirements.php
  36. 2 0
      runtime/.gitignore
  37. 6 0
      tests/_bootstrap.php
  38. 1 0
      tests/_data/.gitkeep
  39. 2 0
      tests/_output/.gitignore
  40. 26 0
      tests/_support/AcceptanceTester.php
  41. 23 0
      tests/_support/FunctionalTester.php
  42. 26 0
      tests/_support/UnitTester.php
  43. 10 0
      tests/acceptance.suite.yml.example
  44. 12 0
      tests/acceptance/AboutCest.php
  45. 34 0
      tests/acceptance/ContactCest.php
  46. 18 0
      tests/acceptance/HomeCest.php
  47. 21 0
      tests/acceptance/LoginCest.php
  48. 1 0
      tests/acceptance/_bootstrap.php
  49. 29 0
      tests/bin/yii
  50. 20 0
      tests/bin/yii.bat
  51. 13 0
      tests/functional.suite.yml
  52. 57 0
      tests/functional/ContactFormCest.php
  53. 59 0
      tests/functional/LoginFormCest.php
  54. 1 0
      tests/functional/_bootstrap.php
  55. 11 0
      tests/unit.suite.yml
  56. 3 0
      tests/unit/_bootstrap.php
  57. 41 0
      tests/unit/models/ContactFormTest.php
  58. 51 0
      tests/unit/models/LoginFormTest.php
  59. 44 0
      tests/unit/models/UserTest.php
  60. 2 0
      vagrant/config/.gitignore
  61. 22 0
      vagrant/config/vagrant-local.example.yml
  62. 38 0
      vagrant/nginx/app.conf
  63. 3 0
      vagrant/nginx/log/.gitignore
  64. 18 0
      vagrant/provision/always-as-root.sh
  65. 79 0
      vagrant/provision/once-as-root.sh
  66. 31 0
      vagrant/provision/once-as-vagrant.sh
  67. 50 0
      vagrant/provision/provision.awk
  68. 38 0
      views/inicio/index.php
  69. 18 0
      views/layouts/main.php
  70. 87 0
      views/login/index.php
  71. 318 0
      views/site/asignar-descarga.php
  72. 137 0
      views/site/consulta.php
  73. 27 0
      views/site/error.php
  74. 4 0
      web/.htaccess
  75. 2 0
      web/assets/.gitignore
  76. 84 0
      web/css/site.css
  77. BIN
      web/favicon.ico
  78. 16 0
      web/index-test.php
  79. 12 0
      web/index.php
  80. 2 0
      web/robots.txt
  81. 76 0
      widgets/Alert.php
  82. 21 0
      yii
  83. 20 0
      yii.bat

+ 3 - 0
.bowerrc

@@ -0,0 +1,3 @@
+{
+    "directory" : "vendor/bower-asset"
+}

+ 34 - 0
.gitignore

@@ -0,0 +1,34 @@
+# phpstorm project files
+.idea
+.vscode
+
+# netbeans project files
+nbproject
+
+# zend studio for eclipse project files
+.buildpath
+.project
+.settings
+
+# windows thumbnail cache
+Thumbs.db
+
+# composer vendor dir
+/vendor
+
+# composer itself is not needed
+composer.phar
+
+# Mac DS_Store Files
+.DS_Store
+
+# phpunit itself is not needed
+phpunit.phar
+# local phpunit config
+/phpunit.xml
+
+tests/_output/*
+tests/_support/_generated
+
+#vagrant folder
+/.vagrant

+ 29 - 0
LICENSE.md

@@ -0,0 +1,29 @@
+Copyright © 2008 by Yii Software LLC (http://www.yiisoft.com)
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+ * Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in
+   the documentation and/or other materials provided with the
+   distribution.
+ * Neither the name of Yii Software LLC nor the names of its
+   contributors may be used to endorse or promote products derived
+   from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.

+ 233 - 0
README.md

@@ -0,0 +1,233 @@
+<p align="center">
+    <a href="https://github.com/yiisoft" target="_blank">
+        <img src="https://avatars0.githubusercontent.com/u/993323" height="100px">
+    </a>
+    <h1 align="center">Yii 2 Basic Project Template</h1>
+    <br>
+</p>
+
+Yii 2 Basic Project Template is a skeleton [Yii 2](http://www.yiiframework.com/) application best for
+rapidly creating small projects.
+
+The template contains the basic features including user login/logout and a contact page.
+It includes all commonly used configurations that would allow you to focus on adding new
+features to your application.
+
+[![Latest Stable Version](https://img.shields.io/packagist/v/yiisoft/yii2-app-basic.svg)](https://packagist.org/packages/yiisoft/yii2-app-basic)
+[![Total Downloads](https://img.shields.io/packagist/dt/yiisoft/yii2-app-basic.svg)](https://packagist.org/packages/yiisoft/yii2-app-basic)
+[![build](https://github.com/yiisoft/yii2-app-basic/workflows/build/badge.svg)](https://github.com/yiisoft/yii2-app-basic/actions?query=workflow%3Abuild)
+
+DIRECTORY STRUCTURE
+-------------------
+
+      assets/             contains assets definition
+      commands/           contains console commands (controllers)
+      config/             contains application configurations
+      controllers/        contains Web controller classes
+      mail/               contains view files for e-mails
+      models/             contains model classes
+      runtime/            contains files generated during runtime
+      tests/              contains various tests for the basic application
+      vendor/             contains dependent 3rd-party packages
+      views/              contains view files for the Web application
+      web/                contains the entry script and Web resources
+
+
+
+REQUIREMENTS
+------------
+
+The minimum requirement by this project template that your Web server supports PHP 5.6.0.
+
+
+INSTALLATION
+------------
+
+### Install via Composer
+
+If you do not have [Composer](http://getcomposer.org/), you may install it by following the instructions
+at [getcomposer.org](http://getcomposer.org/doc/00-intro.md#installation-nix).
+
+You can then install this project template using the following command:
+
+~~~
+composer create-project --prefer-dist yiisoft/yii2-app-basic basic
+~~~
+
+Now you should be able to access the application through the following URL, assuming `basic` is the directory
+directly under the Web root.
+
+~~~
+http://localhost/basic/web/
+~~~
+
+### Install from an Archive File
+
+Extract the archive file downloaded from [yiiframework.com](http://www.yiiframework.com/download/) to
+a directory named `basic` that is directly under the Web root.
+
+Set cookie validation key in `config/web.php` file to some random secret string:
+
+```php
+'request' => [
+    // !!! insert a secret key in the following (if it is empty) - this is required by cookie validation
+    'cookieValidationKey' => '<secret random string goes here>',
+],
+```
+
+You can then access the application through the following URL:
+
+~~~
+http://localhost/basic/web/
+~~~
+
+
+### Install with Docker
+
+Update your vendor packages
+
+    docker-compose run --rm php composer update --prefer-dist
+    
+Run the installation triggers (creating cookie validation code)
+
+    docker-compose run --rm php composer install    
+    
+Start the container
+
+    docker-compose up -d
+    
+You can then access the application through the following URL:
+
+    http://127.0.0.1:8000
+
+**NOTES:** 
+- Minimum required Docker engine version `17.04` for development (see [Performance tuning for volume mounts](https://docs.docker.com/docker-for-mac/osxfs-caching/))
+- The default configuration uses a host-volume in your home directory `.docker-composer` for composer caches
+
+
+CONFIGURATION
+-------------
+
+### Database
+
+Edit the file `config/db.php` with real data, for example:
+
+```php
+return [
+    'class' => 'yii\db\Connection',
+    'dsn' => 'mysql:host=localhost;dbname=yii2basic',
+    'username' => 'root',
+    'password' => '1234',
+    'charset' => 'utf8',
+];
+```
+
+**NOTES:**
+- Yii won't create the database for you, this has to be done manually before you can access it.
+- Check and edit the other files in the `config/` directory to customize your application as required.
+- Refer to the README in the `tests` directory for information specific to basic application tests.
+
+
+TESTING
+-------
+
+Tests are located in `tests` directory. They are developed with [Codeception PHP Testing Framework](http://codeception.com/).
+By default there are 3 test suites:
+
+- `unit`
+- `functional`
+- `acceptance`
+
+Tests can be executed by running
+
+```
+vendor/bin/codecept run
+```
+
+The command above will execute unit and functional tests. Unit tests are testing the system components, while functional
+tests are for testing user interaction. Acceptance tests are disabled by default as they require additional setup since
+they perform testing in real browser. 
+
+
+### Running  acceptance tests
+
+To execute acceptance tests do the following:  
+
+1. Rename `tests/acceptance.suite.yml.example` to `tests/acceptance.suite.yml` to enable suite configuration
+
+2. Replace `codeception/base` package in `composer.json` with `codeception/codeception` to install full featured
+   version of Codeception
+
+3. Update dependencies with Composer 
+
+    ```
+    composer update  
+    ```
+
+4. Download [Selenium Server](http://www.seleniumhq.org/download/) and launch it:
+
+    ```
+    java -jar ~/selenium-server-standalone-x.xx.x.jar
+    ```
+
+    In case of using Selenium Server 3.0 with Firefox browser since v48 or Google Chrome since v53 you must download [GeckoDriver](https://github.com/mozilla/geckodriver/releases) or [ChromeDriver](https://sites.google.com/a/chromium.org/chromedriver/downloads) and launch Selenium with it:
+
+    ```
+    # for Firefox
+    java -jar -Dwebdriver.gecko.driver=~/geckodriver ~/selenium-server-standalone-3.xx.x.jar
+    
+    # for Google Chrome
+    java -jar -Dwebdriver.chrome.driver=~/chromedriver ~/selenium-server-standalone-3.xx.x.jar
+    ``` 
+    
+    As an alternative way you can use already configured Docker container with older versions of Selenium and Firefox:
+    
+    ```
+    docker run --net=host selenium/standalone-firefox:2.53.0
+    ```
+
+5. (Optional) Create `yii2basic_test` database and update it by applying migrations if you have them.
+
+   ```
+   tests/bin/yii migrate
+   ```
+
+   The database configuration can be found at `config/test_db.php`.
+
+
+6. Start web server:
+
+    ```
+    tests/bin/yii serve
+    ```
+
+7. Now you can run all available tests
+
+   ```
+   # run all available tests
+   vendor/bin/codecept run
+
+   # run acceptance tests
+   vendor/bin/codecept run acceptance
+
+   # run only unit and functional tests
+   vendor/bin/codecept run unit,functional
+   ```
+
+### Code coverage support
+
+By default, code coverage is disabled in `codeception.yml` configuration file, you should uncomment needed rows to be able
+to collect code coverage. You can run your tests and collect coverage with the following command:
+
+```
+#collect coverage for all tests
+vendor/bin/codecept run --coverage --coverage-html --coverage-xml
+
+#collect coverage only for unit tests
+vendor/bin/codecept run unit --coverage --coverage-html --coverage-xml
+
+#collect coverage for unit and functional tests
+vendor/bin/codecept run functional,unit --coverage --coverage-html --coverage-xml
+```
+
+You can see code coverage output under the `tests/_output` directory.

+ 92 - 0
Vagrantfile

@@ -0,0 +1,92 @@
+require 'yaml'
+require 'fileutils'
+
+required_plugins_installed = nil
+required_plugins = %w( vagrant-hostmanager vagrant-vbguest )
+required_plugins.each do |plugin|
+  unless Vagrant.has_plugin? plugin
+    system "vagrant plugin install #{plugin}"
+    required_plugins_installed = true
+  end
+end
+
+# IF plugin[s] was just installed - restart required
+if required_plugins_installed
+  # Get CLI command[s] and call again
+  system 'vagrant' + ARGV.to_s.gsub(/\[\"|\", \"|\"\]/, ' ')
+  exit
+end
+
+domains = {
+  app: 'yii2basic.test'
+}
+
+vagrantfile_dir_path = File.dirname(__FILE__)
+
+config = {
+  local: vagrantfile_dir_path + '/vagrant/config/vagrant-local.yml',
+  example: vagrantfile_dir_path + '/vagrant/config/vagrant-local.example.yml'
+}
+
+# copy config from example if local config not exists
+FileUtils.cp config[:example], config[:local] unless File.exist?(config[:local])
+# read config
+options = YAML.load_file config[:local]
+
+# check github token
+if options['github_token'].nil? || options['github_token'].to_s.length != 40
+  puts "You must place REAL GitHub token into configuration:\n/yii2-app-basic/vagrant/config/vagrant-local.yml"
+  exit
+end
+
+# vagrant configurate
+Vagrant.configure(2) do |config|
+  # select the box
+  config.vm.box = 'bento/ubuntu-18.04'
+
+  # should we ask about box updates?
+  config.vm.box_check_update = options['box_check_update']
+
+  config.vm.provider 'virtualbox' do |vb|
+    # machine cpus count
+    vb.cpus = options['cpus']
+    # machine memory size
+    vb.memory = options['memory']
+    # machine name (for VirtualBox UI)
+    vb.name = options['machine_name']
+  end
+
+  # machine name (for vagrant console)
+  config.vm.define options['machine_name']
+
+  # machine name (for guest machine console)
+  config.vm.hostname = options['machine_name']
+
+  # network settings
+  config.vm.network 'private_network', ip: options['ip']
+
+  # sync: folder 'yii2-app-advanced' (host machine) -> folder '/app' (guest machine)
+  config.vm.synced_folder './', '/app', owner: 'vagrant', group: 'vagrant'
+
+  # disable folder '/vagrant' (guest machine)
+  config.vm.synced_folder '.', '/vagrant', disabled: true
+
+  # hosts settings (host machine)
+  config.vm.provision :hostmanager
+  config.hostmanager.enabled            = true
+  config.hostmanager.manage_host        = true
+  config.hostmanager.ignore_private_ip  = false
+  config.hostmanager.include_offline    = true
+  config.hostmanager.aliases            = domains.values
+
+  # quick fix for failed guest additions installations
+  # config.vbguest.auto_update = false
+
+  # provisioners
+  config.vm.provision 'shell', path: './vagrant/provision/once-as-root.sh', args: [options['timezone'], options['ip']]
+  config.vm.provision 'shell', path: './vagrant/provision/once-as-vagrant.sh', args: [options['github_token']], privileged: false
+  config.vm.provision 'shell', path: './vagrant/provision/always-as-root.sh', run: 'always'
+
+  # post-install message (vagrant console)
+  config.vm.post_up_message = "App URL: http://#{domains[:app]}"
+end

+ 31 - 0
assets/AppAsset.php

@@ -0,0 +1,31 @@
+<?php
+/**
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright (c) 2008 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+namespace app\assets;
+
+use yii\web\AssetBundle;
+
+/**
+ * Main application asset bundle.
+ *
+ * @author Qiang Xue <qiang.xue@gmail.com>
+ * @since 2.0
+ */
+class AppAsset extends AssetBundle
+{
+    public $basePath = '@webroot';
+    public $baseUrl = '@web';
+    public $css = [
+        'css/site.css',
+    ];
+    public $js = [
+    ];
+    public $depends = [
+        'yii\web\YiiAsset',
+        'yii\bootstrap4\BootstrapAsset',
+    ];
+}

+ 27 - 0
codeception.yml

@@ -0,0 +1,27 @@
+actor: Tester
+bootstrap: _bootstrap.php
+paths:
+    tests: tests
+    log: tests/_output
+    data: tests/_data
+    helpers: tests/_support
+settings:
+    memory_limit: 1024M
+    colors: true
+modules:
+    config:
+        Yii2:
+            configFile: 'config/test.php'
+
+# To enable code coverage:
+#coverage:
+#    #c3_url: http://localhost:8080/index-test.php/
+#    enabled: true
+#    #remote: true
+#    #remote_config: '../codeception.yml'
+#    whitelist:
+#        include:
+#            - models/*
+#            - controllers/*
+#            - commands/*
+#            - mail/*

+ 867 - 0
commands/CopyController.php

@@ -0,0 +1,867 @@
+<?php
+
+namespace app\commands;
+
+use app\models\Descarga;
+use yii\console\Controller;
+use yii\db\Expression;
+use yii\db\Query;
+
+class CopyController extends Controller {
+
+  public $ciudades = [
+    "03LDf61X" => [
+      "SPCH96_9",
+      "SPCH105_9",
+      "SPCH94_1",
+      "SPCH105_1",
+      "PUE94_9",
+      "SPCH98_7",
+      "SPCH91_7",
+      "PUE103_3",
+      "SPCH90_1",
+      "SPCH102_1",
+      "SPCH92_5",
+    ],
+    "07TNqt6A" => [
+      "MCVA94_7",
+      "MCVA103_1",
+      "MCVA99_5",
+      "MCVA97_1",
+      "MCVA91_1",
+      "MCVA104_9",
+      "MCVA101_1",
+      "MCVA100_3",
+      "MCVA98_7",
+      "MCVA96_3",
+      "MCVA107_1",
+      "MCVA104_1",
+    ],
+    "13MLZC8S" => [
+      "TV_CDMX_3_1",
+      "TV_CDMX_1_1",
+      "TV_CDMX_5_1",
+      "TV_CDMX_7_1",
+      "TV_CDMX_40_1",
+      "TV_CDMX_4_1",
+      "TV_CDMX_8_1",
+      "TV_CDMX_4_1",
+    ],
+    "1wwBGsDq" => [
+      "TV_GDL_17_1",
+      "TV_GDL_44_1",
+    ],
+    "4IpjbpDO" => [
+      "CHI90_1",
+      "CHI92_5",
+      "CHI97_3",
+      "CHI105_3",
+      "CHI106_1",
+      "CHI102_5",
+    ],
+    "5i6lpw3D" => [
+      "TV_XAL_8_1",
+      "TV_XAL_26_1",
+      "TV_XAL_5_1",
+      "TV_XAL_9_1",
+    ],
+    "5T8btSQM" => [
+      "CUE90_3",
+      "CUE93_3",
+      "CUE94_9",
+      "CUE95_7",
+      "CUE96_5",
+      "CUE97_3",
+      "CUE98_1",
+      "CUE99_1",
+      "CUE100_1",
+      "CUE102_9",
+      "CUE106_1",
+      "CUE106_9",
+      "CUE107_7",
+    ],
+    "6qIpZjMR" => [
+      "TV_TUX_1_1",
+      "TV_TUX_10_1",
+      "TV_TUX_2_1",
+      "TV_TUX_7_1",
+    ],
+    "7YDLvHnY" => [
+      "URU100_5",
+      "URU101_3",
+      "URU102_1",
+      "URU89_7",
+      "URU91_1",
+      "URU93_7",
+      "URU99_7",
+    ],
+    "8Duwg4qe" => [
+      "SNG95_7",
+      "SNG92_5",
+      "SNG94_9",
+      "SNG90_5",
+      "SNG91_7",
+    ],
+    "9CkyTkDk" => [
+      "TV_COL_2_1",
+      "TV_COL_5_1",
+      "TV_COL_7_1",
+      "TV_COL_3_1",
+    ],
+    "9NBfrirA" => [
+      "TV_TEP_7_1",
+      "TV_TEP_5_1",
+      "TV_TEP_10_1",
+    ],
+    "9R9SiR7p" => [
+      "MAT97_7",
+      "MAT_91_3",
+    ],
+    "9TJwI7aV" => [
+      "TV_CDMX_22_1",
+      "TV_CDMX_14_2",
+      "TV_CDMX_6_2",
+      "TV_CDMX_6_3",
+    ],
+    "B3inty0" => [
+      "QUE91_7",
+      "QUE98_7",
+      "QUE104_9",
+      "QUE95_5",
+      "QUE94_7",
+      "QUE107_9",
+      "QUE100_3",
+      "QUE100_3",
+    ],
+    "Br0k3yZx" => [
+      "101_1_GDL",
+      "95_5_GDL",
+    ],
+    "ca1qqivn" => [
+      "TV_LEON_3_1",
+      "TV_LEON_5_1",
+      "TV_LEO_6_1",
+      "TV_LEO_2_1",
+    ],
+    "CqKnAoz2" => [
+      "TV_VER_2_1",
+      "TV_VER_12_1",
+    ],
+    "cXZamnSk" => [
+      "TV_MOR_13_1",
+      "TV_MOR_3_1",
+      "TV_MOR_7_1",
+      "TV_MOR_14_1",
+      "TV_MOR_5_1",
+    ],
+    "D4nytzP" => [
+      "AGU106_9",
+      "AGU95_7",
+      "AGU93_7",
+      "AGU100_1",
+      "AGU88_7",
+      "AGU100_9",
+      "AGU102_9",
+      "AGU98_1",
+      "AGU91_3",
+      "AGU92_7",
+      "AGU94_5",
+      "AGU90_5",
+      "AGU105_3",
+      "AGU107_7",
+    ],
+    "denPJ1FV" => [
+      "ODJ89_7",
+      "ODJ93_7",
+      "ODJ94_9",
+      "ODJ95_7",
+      "ODJ96_9",
+      "ODJ97_7",
+      "ODJ100_1",
+      "ODJ101_7",
+      "ODJ105_7",
+      "ODJ100_9",
+    ],
+    "DIW67ZVD" => [
+      "TUX98_5",
+      "TUX96_9",
+      "TUX105_9",
+      "TUX93_1",
+      "TUX88_3",
+      "TUX90_3",
+      "TUX93_9",
+      "TUX96_1",
+      "TUX103_5",
+      "TUX102_5",
+      "TUX100_1",
+      "TUX101_7",
+    ],
+    "dvRKE2Q-" => [
+      "TLA96_5",
+      "TLA99_5",
+      "TLA100_3",
+    ],
+    "eFopFzT1" => [
+      "TV_VIC_7_1",
+      "TV_VIC_3_1",
+      "TV_VIC_2_1",
+      "TV_VIC_5_1",
+    ],
+    "eLJmNJ5gG1H" => [
+      "102_7HMO",
+      "97_1HMO",
+      "101_1HMO",
+      "107_5HMO",
+      "97_1HMO",
+    ],
+    "enOjw_tZ" => [
+      "TV_MCVA_1_1",
+      "TV_MCVA_7_1",
+      "TV_MCVA_2_1",
+      "TV_MCVA_5_1",
+    ],
+    "FTiguGXr" => [
+      "TOL93_3",
+      "TOL90_1",
+      "TOL91_7",
+      "TOL98_9",
+      "TOL102_1",
+      "TOL99_7",
+      "TOL101_3",
+    ],
+    "Ga6wUhMb" => [
+      "TV_TOR_5_1",
+      "TV_TOR_1_1",
+      "TV_TOR_7_1",
+      "TV_TOR_2_1",
+    ],
+    "gFlzFvnx" => [
+      "TV_QUE_5_1",
+      "TV_QUE_3_1",
+      "TV_QUE_11_1",
+      "TV_QUE_9_1",
+    ],
+    "GSIU4U2m" => [
+      "LEON107_1",
+      "LEON95_5",
+      "LEON101_1",
+      "LEON98_3",
+      "LEON104_1",
+      "LEON90_3",
+      "LEON92_3",
+      "LEON93_9",
+      "LEON99_9",
+    ],
+    "GwCBvAxo" => [
+      "TV_CAN_2_1",
+      "TV_CAN_5_1",
+    ],
+    "h0E6IAeR" => [
+      "DUR101_3",
+      "DUR95_7",
+      "DUR98_9",
+      "DUR105_3",
+      "DUR100_5",
+      "DUR102_9",
+      "DUR94_1",
+      "DUR92_9",
+    ],
+    "h4QN6Pde" => [
+      "TV_REY_7_1",
+      "TV_REY_1_2",
+      "TV_REY_1_1",
+      "TV_REY_6_2",
+    ],
+    "IHxtMrao" => [
+      "TV_PUE_3_1",
+      "TV_PUE_4_1",
+      "TV_PUE_26_1",
+      "TV_PUE_1_1",
+      "TV_PUE_14_1",
+    ],
+    "IJlxRs9K" => [
+      "TEP96_1",
+      "TEP98_5",
+      "TEP97_7",
+      "TEP101_9",
+      "TEP95_3",
+      "TEP93_7",
+      "TEP101_1",
+      "TEP92_1",
+      "TEP104_9",
+    ],
+    "iqIRfGOb" => [
+      "88_9HMO",
+      "91_5HMO",
+    ],
+    "irztrhmN" => [
+      "REY101_5",
+      "REY102_5",
+      "REY95_3",
+    ],
+    "JAMI2CKo" => [
+      "JUA103_5",
+      "JUA100_7",
+      "JUA105_1",
+      "JUA107_5",
+      "JUA104_3",
+      "JUA106_7",
+    ],
+    "jElsmiSu" => [
+      "TV_2_1_SON_HMO_AIR",
+      "TV_9_1_SON_HMO_AIR",
+      "TV_5_1_SON_HMO_AIR",
+    ],
+    "jLMtcEIk" => [
+      "TV_CUL_2_1",
+      "TV_CUL_1_1",
+      "TV_ACA_7_1",
+      "TV_ACA_9_1",
+      "TV_ACA_5_1",
+      "TV_ACA_2_1",
+      "TV_CUL_5_1",
+      "TV_CUL_11_1",
+    ],
+    "jujYqh0S" => [
+      "MER105_9",
+      "MER97_7",
+      "MER95_3",
+      "MER90_9",
+      "MER98_5",
+      "MER103_1",
+      "MER103_9",
+      "MER94_5",
+      "MER105_1",
+      "MER105_9",
+    ],
+    "jYnLHGxp" => [
+      "TV_TIJ_12_1",
+      "TV_TIJ_3_1",
+      "TV_TIJ_21_1",
+      "TV_TIJ_33_1",
+    ],
+    "Jz6LdVsk" => [
+      "PCH98_1",
+      "PCH95_7",
+      "PCH106_1",
+      "PCH92_5",
+      "PCH104_5",
+      "PCH99_7",
+    ],
+    "k1bOV7pD" => [
+      "TV_CUE_1_1",
+      "TV_CUE_9_1",
+      "TV_CUE_11_1",
+    ],
+    "KFB8tKff" => [
+      "TV_MEX_1_1",
+      "TV_MEX_2_1",
+      "TV_MEX_10_1",
+      "TV_MEX_5_1",
+    ],
+    "KNbHfLHQ" => [
+      "TV_MTY_5_1",
+      "TV_MTY_8_1",
+      "TV_SNG_2_1",
+      "TV_MON_7_1"
+    ],
+    "kQw04CrV" => [
+      "TV_CDMX_2_1",
+      "TV_CDMX_34_1",
+      "TV_CDMX_9_1",
+      "TV_CDMX_45_1",
+    ],
+    "Lq2GWUH6" => [
+      "MOR105_1",
+      "MOR100_9",
+      "MOR93_1",
+      "MOR103_1",
+      "MOR91_5",
+      "MOR97_3",
+      "MOR98_1",
+      "MOR101_7",
+      "MOR106_9",
+      "MOR102_5",
+      "MOR104_3",
+    ],
+    "Lq50GGwP" => [
+      "VIC99_3",
+      "VIC95_3",
+      "VIC96_1",
+      "VIC102_5",
+      "VIC107_9",
+      "VIC101_7",
+    ],
+    "LTAUmjc4" => [
+      "TV_15_1_SON_HMO_AIR",
+      "TV_12_1_SON_HMO_AIR",
+      "TV_.7_1_SON_HMO_AIR",
+      "TV_1_1_SON_HMO_AIR",
+    ],
+    "mGeawnyY" => [
+      "LPZ99_1",
+      "LPZ92_7",
+      "LPZ105_5",
+      "LPZ97_5",
+      "LPZ98_3",
+      "LPZ95_9",
+      "LPZ96_7",
+      "LPZ100_7",
+    ],
+    "MKQ1k5co" => [
+      "TV_LPZ_1_2",
+      "TV_LPZ_5_1",
+      "TV_LPZ_2_1",
+      "TV_LPZ_7_1",
+    ],
+    "mNMrXYUB" => [
+      "ACA92_1",
+      "ACA102_3",
+      "ACA105_5",
+      "ACA103_1",
+      "ACA101_5",
+      "ACA95_3",
+      "ACA89_7",
+      "ACA99_3",
+      "ACA94_5",
+      "ACA93_7",
+      "ACA100_1",
+    ],
+    "MUiVNylI" => [
+      "TV_TOL_1_1",
+      "TV_TOL_3_1",
+      "TV_TOL_7_1",
+    ],
+    "nAFZnAYr" => [
+      "CUL91_9",
+      "CUL104_1",
+      "CUL92_9",
+      "CUL101_7",
+      "CUL88_7",
+      "CUL94_5",
+      "CUL95_3",
+      "CUL103_3",
+      "CUL96_9",
+      "CUL102_5",
+    ],
+    "NPxBYVb4" => [
+      "TV_CHI_1_3",
+      "TV_CHI_2_1",
+      "TV_CHI_3_1",
+    ],
+    "Ns47mTar" => [
+      "TV_PCH_3_1",
+      "TV_PCH_12_1",
+      "TV_PCH_7_1",
+    ],
+    "opNQYeRG" => [
+      "TV_AGU_5_1",
+      "TV_AGU_9_1",
+      "TV_AGU_1_1",
+      "TV_AGU_7_1",
+    ],
+    "OWCiV9M9" => [
+      "MEX_CDMX_93_7",
+      "MEX_CDMX_89_7",
+      "MEX_CDMX_92_9",
+      "MEX_CDMX_103_3",
+      "MEX_CDMX_96_9",
+      "CDMX101_7",
+      "MEX_CDMX_92_1",
+      "MEX_CDMX_88_9",
+      "MEX_CDMX_95_3",
+      "MEX_CDMX_94_1",
+    ],
+    "P5tpQ9Fv" => [
+      "SLP102_1",
+      "SLP95_3",
+      "SLP96_9",
+      "SLP94_1",
+      "SLP99_3",
+      "SLP88_5",
+    ],
+    "pcOFIelT" => [
+      "XAL104_9",
+      "XAL97_7",
+      "XAL91_7",
+      "XAL95_5",
+      "XAL98_5",
+      "XAL107_7",
+    ],
+    "pOl60TJD" => [
+      "BEN102_7",
+      "BEN92_3",
+      "BEN101_9",
+      "BEN99_3",
+      "BEN105_9",
+      "BEN105_1",
+      "BEN90_7",
+      "BEN104_3",
+      "BEN93_1",
+    ],
+    "R0p3Ngr" => [
+      "TIJ91_1",
+      "TIJ94_5",
+      "TIJ107_3",
+      "TIJ95_3",
+      "TIJ88_7",
+      "TIJ90_3",
+      "TIJ104_9",
+      "TIJ98_9",
+      "TIJ99_3",
+      "TIJ105_7",
+      "TIJ92_5",
+      "TIJ107_7",
+      "TIJ90_7",
+      "TIJ99_7",
+    ],
+    "Reickz4Z" => [
+      "VER_106_1",
+      "VER_94_1",
+      "VER_90_1",
+      "VER_99_7",
+      "VER_102_9",
+      "VER_98_1",
+    ],
+    "RonDKfBH" => [
+      "TV_JUA_2_1",
+      "TV_JUA_20_1",
+      "TV_JUA_44_1",
+      "TV_JUA_5_1",
+    ],
+    "rt-MOm8-" => [
+      "2107_3_MEX",
+      "MEX_CDMX_94_5",
+      "MEX_CDMX_100_9",
+      "105_3_MEX",
+      "MEX_CDMX_90_5",
+      "MEX_CDMX_96_1",
+      "MEX_CDMX_104_1",
+    ],
+    "S1eMpr3" => [
+      "CEN88_5",
+      "CEN98_3",
+      "CEN89_3",
+      "CEN104_9",
+      "CEN106_3",
+      "CEN90_1",
+      "CEN95_7",
+      "CEN92_5",
+      "CEN_94_9",
+      "CEN107_3",
+      "CEN97_3",
+      "CEN103_3",
+      "CEN104_1",
+    ],
+    "s3c6lpcx" => [
+      "TV_MER_1_1",
+      "TV_MER_5_1",
+      "TV_MER_2_1",
+    ],
+    "S3IiFs1W" => [
+      "TV_DUR_1_1",
+      "TV_DUR_2_1",
+      "TV_DUR_7_1",
+      "TV_DUR_5_1",
+    ],
+    "sr0SzB2n" => [
+      "TV_PDN_1_1",
+      "TV_PDN_5_1",
+      "TV_PDN_9_1",
+      "TV_PDN_2_1",
+    ],
+    "TEFCS0Ix" => [
+      "COL89_3",
+      "COL90_5",
+      "COL98_9",
+      "COL98_1",
+      "COL104_5",
+      "COL94_9",
+      "COL92_5",
+    ],
+    "TJITznDM" => [
+      "MAZ91_3",
+      "MAZ104_3",
+      "MAZ88_9",
+    ],
+    "tKykZpfA" => [
+      "COA93_1",
+      "COA98_5",
+      "COA103_5",
+      "COA100_1",
+    ],
+    "Tpz5vBCW" => [
+      "TV_TAB_46_1",
+      "TV_TAB_13_1",
+      "TV_TAB_35_1",
+      "TV_TAB_1_1",
+      "TV_TAB_7_1",
+      "TV_TAB_2_1",
+    ],
+    "tQ-bDD6u" => [
+      "TV_SAL_9_1",
+      "TV_SAL_3_1",
+      "TV_SAL_7_1",
+    ],
+    "Tri3mym9" => [
+      "PDN101_7",
+      "PDN103_7",
+      "PDN96_7",
+      "PDN97_9",
+      "PDN107_9",
+      "PDN106_3",
+      "PDN105_5",
+      "PDN99_9",
+      "PDN99_1",
+      "PDN94_5",
+      "PDN104_3",
+    ],
+    "tu84G13a" => [
+      "TOR93_1",
+      "TOR100_3",
+      "TOR97_9",
+      "TOR95_5",
+    ],
+    "TzDF7cq7" => [
+      "TV_MAZ_3_1",
+      "TV_MAZ_7_1",
+      "TV_MAZ_1_1",
+      "TV_MAZ_5_1",
+    ],
+    "u1JB9Dmw" => [
+      "MEX104_1",
+      "MEX105_5",
+      "MEX91_5",
+      "MEX104_9",
+      "MEX92_3",
+      "MEX90_7",
+      "MEX96_9",
+      "MEX101_9",
+      "MEX105_9",
+      "MEX103_3",
+      "MEX98_3",
+    ],
+    "uHVcwWWD" => [
+      "IRA98_9",
+      "IRA88_5",
+      "IRA102_7",
+      "IRA106_3",
+      "IRA94_3",
+      "IRA95_1",
+      "IRA93_5",
+      "IRA107_9",
+      "IRA91_9",
+    ],
+    "uovj5aMC" => [
+      "TV_MON_4_1",
+      "TV_MON_6_1",
+      "TV_MON_3_1",
+      "TV_MON_28_1",
+    ],
+    "UP4itzbr" => [
+      "MEX_CDMX_90_9",
+    ],
+    "V50Aia9U" => [
+      "NLR95_7",
+      "NLR90_9",
+      "NLR91_3",
+      "NLR96_1",
+      "NLR97_1",
+      "NLR99_3",
+      "NLR101_5",
+      "NLR102_3",
+      "NLR107_3",
+    ],
+    "V6QOaSZ5" => [
+      "TV_MAT_9_1",
+      "TV_MAT_8_1",
+      "TV_MAT_1_1",
+      "TV_MAT_2_1",
+    ],
+    "VhTifIvKH8" => [
+      "93_9HMO",
+      "99_5HMO",
+      "100_3HMO",
+      "94_7HMO",
+    ],
+    "vNFU8lIw" => [
+      "TV_SLP_3_1",
+      "TV_SAN LUIS POTOSI_5_1",
+    ],
+    "w1Enz_y0" => [
+      "TV_OAX_2_1",
+      "TV_OAX_4_1",
+      "TV_OAX_3_1",
+      "TV_OAX_7_1",
+    ],
+    "wDbc0Qll" => [
+      "TV_COA_1_1",
+      "TV_COA_2_1",
+      "TV_COA_3_1",
+      "TV_COA_5_1",
+    ],
+    "x8g8AZF0" => [
+      "MEX_CDMX_104_1",
+      "MEX_CDMX_97_7",
+      "MEX_CDMX_106_1",
+      "MEX_CDMX_91_3",
+      "MEX_CDMX_100_1",
+      "MEX_CDMX_102_5",
+      "MEX_CDMX_105_7",
+      "MEX_CDMX_99_3",
+      "MEX_CDMX_88_15",
+      "MEX_CDMX_106_1",
+      "MEX_CDMX_107_9",
+      "MEX_CDMX_95_7",
+    ],
+    "YCe8v6ZH" => [
+      "SAL88_9",
+      "SAL91_3",
+      "SAL104_9",
+      "SAL93_5",
+      "SAL99_3",
+    ],
+    "Yj1DLiHM" => [
+      "TV_TIJ_6_1",
+      "TV_TIJ_45_1",
+      "TV_TIJ_1_1",
+      "TV_TIJ_19_1",
+    ],
+    "ZXDiy5og" => [
+      "94_1_MTY",
+      "101_3_MTY",
+      "106_1_MTY",
+      "102_9_MTY",
+      "107_7_MTY",
+      "106_9_MTY",
+      "97_3_MTY",
+      "SNG102_1",
+      "MTY93_3",
+      "SNJ98_1",
+    ],
+    "S-_RzKm2" => [
+      "TV_GDL_3_1",
+      "TV_GDL_2_1"
+    ],
+    "g23wXKER" => [
+      "101_9_GDL",
+      "97_1_GDL",
+      "102_7_GDL",
+      "96_3_GDL",
+      "100_3_GDL",
+      "98_7_GDL",
+      "105_1_GDL",
+      "93_9_GDL",
+      "107_5_GDL"
+    ],
+    "8Duwg4qe" => [
+      "106_1_MTY",
+      "106_9_MTY",
+    ]
+  ];
+
+  public $ciudad = null;
+  public $estacion = null;
+  public $fi = null;
+  public $ff = null;
+
+  public function options($actionId) {
+    return [
+      'ciudad',
+      'estacion',
+      'fi',
+      'ff'
+    ];
+  }
+
+  public function actionIndex() {
+    $dbr = \Yii::$app->dbr;
+    while(true) {
+
+      $archivos = (new Query())
+        ->from("file")
+        ->innerJoin("box", "box.id = file.box")
+        ->andWhere([">=", "[[timestamp]] at time zone box.timezone", new Expression("now() - interval '2 days'")])
+        ->orderBy(["timestamp" => SORT_ASC]);
+
+      $condicion = ["OR"];
+      foreach($this->ciudades as $ciudad => $estaciones) {
+        $condicion[] = ["AND", ["box" => $ciudad], ["station" => $estaciones]];
+      }
+      $archivos->andWhere($condicion);
+
+      // $this->stdout($archivos->createCommand()->getRawSql() . "\n");
+
+      foreach($archivos->each(100, $dbr) as $archivo) {
+        try {
+          \Yii::$app->getDb()->createCommand()
+            ->insert("file", [
+              "hash" => $archivo["hash"],
+              "box" => $archivo["box"],
+              "station" => $archivo["station"],
+              "filename" => $archivo["filename"],
+              "timestamp" => $archivo["timestamp"]
+            ])
+            ->execute();
+          $this->stdout("{$archivo["hash"]} {$archivo["box"]} {$archivo["station"]} {$archivo["timestamp"]}\n");
+        } catch(\Exception $e) {
+          $this->stdout("{$archivo["hash"]} {$archivo["box"]} {$archivo["station"]} {$archivo["timestamp"]} ya existe\n");
+        }
+      }
+
+      $this->stdout("Durmiendo por 1 minuto\n");
+      sleep(60);
+    }
+  }
+
+  public function actionUltimo() {
+    $dbr = \Yii::$app->dbr;
+
+    $ultimoArchivo = (new Query())
+        ->from("file")
+        ->orderBy(["timestamp" => SORT_DESC])
+        ->limit(1)
+        ->one($dbr);
+
+    $this->stdout("Fecha: {$ultimoArchivo["timestamp"]}\n");
+  }
+
+  public function actionPorEstacion() {
+    $dbr = \Yii::$app->dbr;
+    $inicio = time();
+    $archivos = (new Query())
+      ->from("file")
+      ->innerJoin("box", "box.id = file.box")
+      ->andWhere([
+        "box" => $this->ciudad,
+        "station" => $this->estacion,
+      ])
+      ->andWhere([">=", "[[timestamp]] at time zone box.timezone", "{$this->fi} 00:00:00"])
+      ->andWhere(["<", "[[timestamp]] at time zone box.timezone", "{$this->ff} 00:00:00"])
+      ->orderBy(["timestamp" => SORT_ASC]);
+
+    // $this->stdout($archivos->createCommand()->getRawSql() . "\n");
+    // return;
+    foreach($archivos->each(100, $dbr) as $archivo) {
+      try {
+        \Yii::$app->getDb()->createCommand()
+          ->insert("file", [
+            "hash" => $archivo["hash"],
+            "box" => $archivo["box"],
+            "station" => $archivo["station"],
+            "filename" => $archivo["filename"],
+            "timestamp" => $archivo["timestamp"]
+          ])
+          ->execute();
+        $this->stdout("{$archivo["hash"]} {$archivo["station"]} {$archivo["box"]} {$archivo["timestamp"]}\n");
+      } catch(\Exception $e) {
+        $this->stdout("{$e->getMessage()}\n");
+      }
+    }
+
+    $tiempo = time() - $inicio;
+
+    $this->stdout("Proceso terminado en: {$tiempo}\n");
+  }
+
+}

+ 151 - 0
commands/SyncCompleteController.php

@@ -0,0 +1,151 @@
+<?php
+
+namespace app\commands;
+
+use app\models\Descarga;
+use yii\console\Controller;
+use yii\console\ExitCode;
+use yii\db\Expression;
+use yii\db\Query;
+use yii\helpers\ArrayHelper;
+
+class SyncCompleteController extends Controller {
+
+  public function actionIndex() {
+    $estados = [
+      'AGUASCALIENTES' => 'AGU',
+      'BAJA CALIFORNIA' => 'BCN',
+      'BAJA CALIFORNIA SUR' => 'BCS',
+      'CAMPECHE' => 'CAM',
+      'CHIAPAS' => 'CHP',
+      'CHIHUAHUA' => 'CHH',
+      'CIUDAD DE MÉXICO' => 'CMX',
+      'COAHUILA' => 'COA',
+      'COLIMA' => 'COL',
+      'DURANGO' => 'DUR',
+      'GUANAJUATO' => 'GUA',
+      'GUERRERO' => 'GRO',
+      'HIDALGO' => 'HID',
+      'JALISCO' => 'JAL',
+      'MEXICO' => 'MEX',
+      'MICHOACAN' => 'MIC',
+      'MORELOS' => 'MOR',
+      'NAYARIT' => 'NAY',
+      'NUEVO LEON' => 'NLE',
+      'OAXACA' => 'OAX',
+      'PUEBLA' => 'PUE',
+      'QUERETARO' => 'QUE',
+      'QUINTANA ROO' => 'ROO',
+      'SAN LUIS POTOSI' => 'SLP',
+      'SINALOA' => 'SIN',
+      'SONORA' => 'SON',
+      'TABASCO' => 'TAB',
+      'TAMAULIPAS' => 'TAM',
+      'TLAXCALA' => 'TLA',
+      'VERACRUZ' => 'VER',
+      'YUCATAN' => 'YUC',
+      'ZACATECAS' => 'ZAC'
+    ];
+    $order = SORT_ASC;
+
+    $ciudadEstacion = (new Query())
+      ->select(["idCiudad", "idEstacion"])
+      ->from("box")
+      ->innerJoin("CiudadEstacion", "box.id = {{CiudadEstacion}}.[[idCiudad]]")
+      ->andWhere([
+        "box.activo" => true
+      ])
+      ->andWhere("[[idGrupo]] is not null")
+      ->all();
+    $idCiudades = ArrayHelper::getColumn($ciudadEstacion, "idCiudad");
+    $ciudades = (new Query())
+      ->select(["id", "nombre", "idEstado", "timezone", "tipo"])
+      ->from("box")
+      ->where(["id" => $idCiudades])
+      ->indexBy("id")
+      ->all();
+    $idEstaciones = ArrayHelper::getColumn($ciudadEstacion, "idEstacion");
+    $estaciones = (new Query())
+      ->select(["id", "clave", "siglas", "frecuencia", "descripcion"])
+      ->from("Estacion")
+      ->andWhere(["id" => $idEstaciones])
+      ->indexBy("clave")
+      ->all();
+
+    while(true) {
+      $inicio = time();
+      $archivos = (new Query())
+        ->select("hash, box, station, filename, [[timestamp]] at time zone box.timezone as timestamp")
+        ->from("file")
+        ->innerJoin("box", "box.id = file.box")
+        ->andWhere([">=", "[[timestamp]] at time zone box.timezone", new Expression("now() - interval '2 day'")])
+        // ->andWhere(["<=", "[[timestamp]] at time zone box.timezone", '2021-10-01 00:00:00'])
+        ->orderBy(["timestamp" => $order]);
+
+      // $this->stdout("{$archivos->createCommand()->getRawSql()}\n");
+
+      $count = 0;
+      foreach($archivos->each() as $archivo) {
+        if(!isset($ciudades[$archivo["box"]])) {
+          continue;
+        }
+        $modelo = new Descarga();
+        $modelo->hash = $archivo["hash"];
+        $modelo->estacion = $archivo["station"];
+        $modelo->archivo = $archivo["filename"];
+        $modelo->ciudad = $archivo["box"];
+        $modelo->pc = null;
+        $modelo->fecha = $archivo["timestamp"];
+        $modelo->descargado = false;
+        $modelo->nombre = basename($archivo["filename"]);
+        $ciudad = $ciudades[$archivo["box"]];
+        $estacion = $estaciones[$archivo["station"]];
+        $fecha = \DateTime::createFromFormat("Y-m-d H:i:s", $archivo["timestamp"]);
+        $siglas = explode("-", $estacion["siglas"]);
+        $tipo = "";
+        $senal = $siglas[0];
+        if(isset($siglas[1])) {
+          $tipo = $siglas[1];
+        }
+        if($tipo === "") {
+          if($ciudad["tipo"] === "tv") {
+            $tipo = "TDT";
+          } elseif ($ciudad["tipo"] === "radio") {
+            $tipo = "FM";
+          } else {
+            $tipo = "AM";
+          }
+        }
+        $nombreCiudad = $ciudad["idEstado"];
+        if($tipo === "AM") {
+          $desc = explode(",", $estacion["descripcion"]);
+          $nombreCiudad = $estacion["descripcion"];
+          if(isset($desc[1])) {
+            $nombreCiudad = $desc[1];
+          }
+        }
+        if(isset($estados[$nombreCiudad])) {
+          $nombreCiudad = $estados[$nombreCiudad];
+        }
+        $nombreCiudad = str_replace(" ", "_", trim($nombreCiudad));
+        $y = $fecha->format("Y");
+        $m = $fecha->format("m");
+        $d = $fecha->format("d");
+        $modelo->ruta = "{$y}/{$tipo}/{$nombreCiudad}/{$senal}/{$m}/{$d}";
+        try {
+          if(!$modelo->save()) {
+            $errores = json_encode($modelo->getFirstErrors());
+            $this->stdout("Error al guardar {$modelo->hash} {$errores}\n");
+          }
+        } catch(\Exception $e) {
+          $this->stdout("Error al guardar {$modelo->hash} {$modelo->archivo}\n");
+        }
+        $count++;
+      }
+      $vuelta = $inicio - time();
+      $this->stdout("Vuelta: {$vuelta}\n");
+    }
+    return ExitCode::OK;
+  }
+
+}

+ 360 - 0
commands/SyncController.php

@@ -0,0 +1,360 @@
+<?php
+
+namespace app\commands;
+
+use app\models\Descarga;
+use yii\console\Controller;
+use yii\console\ExitCode;
+use yii\db\Query;
+use yii\helpers\ArrayHelper;
+
+class SyncController extends Controller {
+
+  public $limite = 5;
+  public $pagina = 1;
+  public $ciudad = null;
+  public $estacion = null;
+  public $fi = null;
+  public $ff = null;
+
+  public function options($actionID) {
+    return [
+      'pagina',
+      'limite',
+      'ciudad',
+      'estacion',
+      'fi',
+      'ff'
+    ];
+  }
+
+
+
+  public function actionIndex() {
+    if($this->pagina < 1) {
+      $this->pagina = 1;
+    }
+
+    $ciudades = (new Query())
+      ->select(["idCiudad"])
+      ->distinct()
+      ->from("box")
+      ->innerJoin("CiudadEstacion", "box.id = {{CiudadEstacion}}.[[idCiudad]]")
+      ->innerJoin("Estacion", "{{Estacion}}.id = {{CiudadEstacion}}.[[idEstacion]]")
+      ->andWhere([
+        "{{CiudadEstacion}}.monitoreable" => true,
+        "box.activo" => true,
+      ])
+      ->andWhere("[[idGrupo]] is not null")
+      ->limit($this->limite)
+      ->offset($this->limite * ($this->pagina - 1))
+      ->orderBy(["idCiudad" => SORT_ASC])
+      ->column();
+
+    return $this->descarga($ciudades);
+  }
+
+  public function descarga($ciudadesIds = []) {
+    $estados = [
+      'AGUASCALIENTES' => 'AGU',
+      'BAJA CALIFORNIA' => 'BCN',
+      'BAJA CALIFORNIA SUR' => 'BCS',
+      'CAMPECHE' => 'CAM',
+      'CHIAPAS' => 'CHP',
+      'CHIHUAHUA' => 'CHH',
+      'CIUDAD DE MÉXICO' => 'CMX',
+      'COAHUILA' => 'COA',
+      'COLIMA' => 'COL',
+      'DURANGO' => 'DUR',
+      'GUANAJUATO' => 'GUA',
+      'GUERRERO' => 'GRO',
+      'HIDALGO' => 'HID',
+      'JALISCO' => 'JAL',
+      'MEXICO' => 'MEX',
+      'MICHOACAN' => 'MIC',
+      'MORELOS' => 'MOR',
+      'NAYARIT' => 'NAY',
+      'NUEVO LEON' => 'NLE',
+      'OAXACA' => 'OAX',
+      'PUEBLA' => 'PUE',
+      'QUERETARO' => 'QUE',
+      'QUINTANA ROO' => 'ROO',
+      'SAN LUIS POTOSI' => 'SLP',
+      'SINALOA' => 'SIN',
+      'SONORA' => 'SON',
+      'TABASCO' => 'TAB',
+      'TAMAULIPAS' => 'TAM',
+      'TLAXCALA' => 'TLA',
+      'VERACRUZ' => 'VER',
+      'YUCATAN' => 'YUC',
+      'ZACATECAS' => 'ZAC'
+    ];
+    $limite = 500;
+    $order = SORT_ASC;
+    $orderUltimo = SORT_DESC; // Siempre tiene que ser el contrario de order
+
+    $ciudadEstacion = (new Query())
+      ->select(["idCiudad", "idEstacion", "clave"])
+      ->from("box")
+      ->innerJoin("CiudadEstacion", "box.id = {{CiudadEstacion}}.[[idCiudad]]")
+      ->innerJoin("Estacion", "{{Estacion}}.id = {{CiudadEstacion}}.[[idEstacion]]")
+      ->andWhere([
+        "monitoreable" => true,
+        "box.activo" => true,
+        "idCiudad" => $ciudadesIds
+      ])
+      ->andWhere("[[idGrupo]] is not null")
+      ->all();
+    $idCiudades = ArrayHelper::getColumn($ciudadEstacion, "idCiudad");
+    $ciudades = (new Query())
+      ->select(["id", "nombre", "idEstado", "timezone", "tipo"])
+      ->from("box")
+      ->where(["id" => $idCiudades])
+      ->indexBy("id")
+      ->all();
+    $idEstaciones = ArrayHelper::getColumn($ciudadEstacion, "idEstacion");
+    $estaciones = (new Query())
+      ->select(["id", "clave", "siglas", "frecuencia", "descripcion"])
+      ->from("Estacion")
+      ->andWhere(["id" => $idEstaciones])
+      ->indexBy("clave")
+      ->all();
+
+    while(true) {
+      foreach($ciudadEstacion as $ce) {
+        $inicio = time();
+        $archivos = (new Query())
+          ->select("hash, box, station, filename, [[timestamp]] at time zone box.timezone as timestamp")
+          ->from("file")
+          ->innerJoin("box", "box.id = file.box")
+          ->andWhere([">=", "[[timestamp]] at time zone box.timezone", '2021-12-01 00:00:00'])
+          // ->andWhere(["<=", "[[timestamp]] at time zone box.timezone", '2021-10-01 00:00:00'])
+          ->andWhere([
+            "box" => $ce["idCiudad"],
+            "station" => $ce["clave"]
+          ])
+          ->limit($limite)
+          ->orderBy(["timestamp" => $order]);
+
+        $ultimo = (new Query())
+          ->select("fecha")
+          ->from("Descarga")
+          ->andWhere([
+            "ciudad" => $ce["idCiudad"],
+            "estacion" => $ce["clave"]
+          ])
+          ->andWhere([">=", "fecha", '2021-12-01 00:00:00'])
+          ->orderBy(["fecha" => $orderUltimo])
+          ->limit(1);
+
+        $this->stdout("{Ultimo query: {$ultimo->createCommand()->getRawSql()}\n");
+        $ultimo = $ultimo->scalar();
+        $this->stdout("Ultimo: {$ultimo}\n");
+
+        if($ultimo) {
+          if($order === SORT_DESC) {
+            $archivos->andWhere(["<=", "timestamp", $ultimo]);
+          } else {
+            $archivos->andWhere([">=", "timestamp", $ultimo]);
+          }
+        }
+
+        $this->stdout("Archivos query: {$archivos->createCommand()->getRawSql()}\n");
+
+        /*
+        $sql = $archivos->createCommand()->getRawSql();
+        $this->stdout("{$sql}\n");
+        // $this->stdout("ultimo: {$ultimo}\n"); 
+        // */
+        
+        $count = 0;
+        foreach($archivos->each() as $archivo) {
+          $modelo = new Descarga();
+          $modelo->hash = $archivo["hash"];
+          $modelo->estacion = $archivo["station"];
+          $modelo->archivo = $archivo["filename"];
+          $modelo->ciudad = $archivo["box"];
+          $modelo->pc = null;
+          $modelo->fecha = $archivo["timestamp"];
+          $modelo->descargado = false;
+          $modelo->nombre = basename($archivo["filename"]);
+          $ciudad = $ciudades[$archivo["box"]];
+          $estacion = $estaciones[$archivo["station"]];
+          $fecha = \DateTime::createFromFormat("Y-m-d H:i:s", $archivo["timestamp"]);
+          $siglas = explode("-", $estacion["siglas"]);
+          $tipo = "";
+          $senal = $siglas[0];
+          if(isset($siglas[1])) {
+            $tipo = $siglas[1];
+          }
+          if($tipo === "") {
+            if($ciudad["tipo"] === "tv") {
+              $tipo = "TDT";
+            } elseif ($ciudad["tipo"] === "radio") {
+              $tipo = "FM";
+            } else {
+              $tipo = "AM";
+            }
+          }
+          $nombreCiudad = $ciudad["idEstado"];
+          if($tipo === "AM") {
+            $desc = explode(",", $estacion["descripcion"]);
+            $nombreCiudad = $estacion["descripcion"];
+            if(isset($desc[1])) {
+              $nombreCiudad = $desc[1];
+            }
+          }
+          if(isset($estados[$nombreCiudad])) {
+            $nombreCiudad = $estados[$nombreCiudad];
+          }
+          $nombreCiudad = str_replace(" ", "_", trim($nombreCiudad));
+          $y = $fecha->format("Y");
+          $m = $fecha->format("m");
+          $d = $fecha->format("d");
+          $modelo->ruta = "{$y}/{$tipo}/{$nombreCiudad}/{$senal}/{$m}/{$d}";
+          try {
+            if(!$modelo->save()) {
+              $errores = json_encode($modelo->getFirstErrors());
+              $this->stdout("Error al guardar {$modelo->hash} {$errores}\n");
+            }
+          } catch(\Exception $e) {
+            $this->stdout("Error al guardar {$modelo->hash}\n");
+          }
+          $count++;
+        }
+        // */
+        $seg = time() - $inicio;
+        $this->stdout("[{$ce["idCiudad"]}][{$ce["clave"]}] {$count} registros insertados en {$seg} segundos\n");
+      }
+
+      $this->stdout("Durmiendo 10 segundos...\n");
+      sleep(10);
+    }
+    return ExitCode::OK;
+  }
+
+  public function actionPorEstacion() {
+    $estados = [
+      'AGUASCALIENTES' => 'AGU',
+      'BAJA CALIFORNIA' => 'BCN',
+      'BAJA CALIFORNIA SUR' => 'BCS',
+      'CAMPECHE' => 'CAM',
+      'CHIAPAS' => 'CHP',
+      'CHIHUAHUA' => 'CHH',
+      'CIUDAD DE MÉXICO' => 'CMX',
+      'COAHUILA' => 'COA',
+      'COLIMA' => 'COL',
+      'DURANGO' => 'DUR',
+      'GUANAJUATO' => 'GUA',
+      'GUERRERO' => 'GRO',
+      'HIDALGO' => 'HID',
+      'JALISCO' => 'JAL',
+      'MEXICO' => 'MEX',
+      'MICHOACAN' => 'MIC',
+      'MORELOS' => 'MOR',
+      'NAYARIT' => 'NAY',
+      'NUEVO LEON' => 'NLE',
+      'OAXACA' => 'OAX',
+      'PUEBLA' => 'PUE',
+      'QUERETARO' => 'QUE',
+      'QUINTANA ROO' => 'ROO',
+      'SAN LUIS POTOSI' => 'SLP',
+      'SINALOA' => 'SIN',
+      'SONORA' => 'SON',
+      'TABASCO' => 'TAB',
+      'TAMAULIPAS' => 'TAM',
+      'TLAXCALA' => 'TLA',
+      'VERACRUZ' => 'VER',
+      'YUCATAN' => 'YUC',
+      'ZACATECAS' => 'ZAC'
+    ];
+    $order = SORT_ASC;
+
+    $ciudades = (new Query())
+      ->select(["id", "nombre", "idEstado", "timezone", "tipo"])
+      ->from("box")
+      ->where(["id" => $this->ciudad])
+      ->indexBy("id")
+      ->all();
+    $estaciones = (new Query())
+      ->select(["id", "clave", "siglas", "frecuencia", "descripcion"])
+      ->from("Estacion")
+      ->andWhere(["clave" => $this->estacion])
+      ->indexBy("clave")
+      ->all();
+
+    $inicio = time();
+    $archivos = (new Query())
+      ->select("hash, box, station, filename, [[timestamp]] at time zone box.timezone as timestamp")
+      ->from("file")
+      ->innerJoin("box", "box.id = file.box")
+      ->andWhere([">=", "[[timestamp]] at time zone box.timezone", $this->fi])
+      ->andWhere(["<=", "[[timestamp]] at time zone box.timezone", $this->ff])
+      ->andWhere([
+        "box" => $this->ciudad,
+        "station" => $this->estacion
+      ])
+      ->orderBy(["timestamp" => $order]);
+
+    $count = 0;
+    foreach($archivos->each() as $archivo) {
+      $modelo = new Descarga();
+      $modelo->hash = $archivo["hash"];
+      $modelo->estacion = $archivo["station"];
+      $modelo->archivo = $archivo["filename"];
+      $modelo->ciudad = $archivo["box"];
+      $modelo->pc = null;
+      $modelo->fecha = $archivo["timestamp"];
+      $modelo->descargado = false;
+      $modelo->nombre = basename($archivo["filename"]);
+      $ciudad = $ciudades[$archivo["box"]];
+      $estacion = $estaciones[$archivo["station"]];
+      $fecha = \DateTime::createFromFormat("Y-m-d H:i:s", $archivo["timestamp"]);
+      $siglas = explode("-", $estacion["siglas"]);
+      $tipo = "";
+      $senal = $siglas[0];
+      if(isset($siglas[1])) {
+        $tipo = $siglas[1];
+      }
+      if($tipo === "") {
+        if($ciudad["tipo"] === "tv") {
+          $tipo = "TDT";
+        } elseif ($ciudad["tipo"] === "radio") {
+          $tipo = "FM";
+        } else {
+          $tipo = "AM";
+        }
+      }
+      $nombreCiudad = $ciudad["idEstado"];
+      if($tipo === "AM") {
+        $desc = explode(",", $estacion["descripcion"]);
+        $nombreCiudad = $estacion["descripcion"];
+        if(isset($desc[1])) {
+          $nombreCiudad = $desc[1];
+        }
+      }
+      if(isset($estados[$nombreCiudad])) {
+        $nombreCiudad = $estados[$nombreCiudad];
+      }
+      $nombreCiudad = str_replace(" ", "_", trim($nombreCiudad));
+      $y = $fecha->format("Y");
+      $m = $fecha->format("m");
+      $d = $fecha->format("d");
+      $modelo->ruta = "{$y}/{$tipo}/{$nombreCiudad}/{$senal}/{$m}/{$d}";
+      try {
+        if(!$modelo->save()) {
+          $errores = json_encode($modelo->getFirstErrors());
+          $this->stdout("Error al guardar {$modelo->hash} {$errores}\n");
+        }
+        $this->stdout("{$modelo->hash} {$modelo->estacion} {$modelo->archivo}\n");
+      } catch(\Exception $e) {
+        $this->stdout("Error al guardar {$modelo->hash}\n");
+      }
+      $count++;
+    }
+    $vuelta = $inicio - time();
+    $this->stdout("Vuelta: {$vuelta}\n");
+    return ExitCode::OK;
+  }
+
+}

+ 170 - 0
components/data/Respuesta.php

@@ -0,0 +1,170 @@
+<?php
+
+namespace app\components\data;
+
+use app\components\rest\Serializer;
+use yii\data\ActiveDataProvider;
+
+$respuesta = new Respuesta();
+
+class Respuesta {
+
+  public $cuerpo = [];
+  protected $db = null;
+  protected $atributosPermitidos = [
+    'resultado',
+    'mensaje',
+    'errores',
+    'detalle',
+    'paginacion'
+  ];
+
+  protected $parametros = [
+    "total" => 0,
+    "pagina" => 0,
+    "limite" => 0,
+    "ordenar" => false
+  ];
+
+  public function __set($nombre, $valor) {
+    if(!in_array($nombre, $this->atributosPermitidos)) {
+      return;
+    }
+    $this->cuerpo[$nombre] = $valor;
+  }
+
+  public function __get($nombre) {
+    if(isset($this->cuerpo[$nombre])) {
+      return $this->cuerpo[$nombre];
+    }
+
+    return null;
+  }
+
+  public function __construct($modelo = null, $limite = 20, $pagina = 1, $ordenar = false, $db = null) {
+    $this->parametros['limite'] = $limite;
+    $this->parametros['pagina'] = $pagina;
+    $this->parametros['ordenar'] = $ordenar;
+    $this->db = $db;
+    if($modelo !== null) {
+      $this->modelo($modelo);
+    }
+  }
+
+  public function modelo($modelo) {
+    $this->esExitoso();
+    if ($modelo instanceof \yii\db\ActiveRecord) {
+      if ($modelo->hasErrors()) {
+        $this->esError();
+        $this->errores = $modelo->getFirstErrors();
+      } else {
+        $this->detalle($modelo->toArray());
+      }
+    } elseif ($modelo instanceof \yii\db\ActiveQuery || $modelo instanceof \yii\db\Query) {
+      \Yii::$app->getResponse()->setStatusCode(200);
+      $req = \Yii::$app->getRequest();
+      $sql = intval($req->get("sql", "")) === 1;
+      if ($sql) {
+        \Yii::$app->getResponse()->format = \yii\web\Response::FORMAT_RAW;
+        echo $modelo->createCommand()->getRawSql();
+        exit(0);
+      }
+      $limite = intval($this->parametros['limite']);
+      $pagina = intval($this->parametros['pagina']);
+      $ordenar = $this->parametros['ordenar'];
+      $total = $modelo->count();
+
+      if($pagina <= 0) {
+        $pagina = 1;
+      }
+
+      $offset = 0;
+      if (($pagina - 1) >= 0) {
+        $offset = $limite * ($pagina - 1);
+      }
+
+      if($offset > 0) {
+        $modelo->offset($offset);
+      }
+
+      $modelo->limit($limite);
+
+      if ($ordenar !== false && ($campo = trim($ordenar)) !== "") {
+        $separar = explode(",", $ordenar);
+        $ordenamiento = [];
+        foreach ($separar as $segmento) {
+          $exp = explode("-", trim($segmento));
+          $desc = false;
+          if (count($exp) > 1) {
+            $campo = $exp[0];
+            $desc = $exp[1] === 'desc';
+          }
+          $ordenamiento[$campo] = $desc ? SORT_DESC : SORT_ASC;
+        }
+        if (!empty($ordenamiento)) {
+          $modelo->orderBy($ordenamiento);
+        }
+      }
+
+      if ($limite > $total || $limite <= 0) {
+        $limite = $total;
+      }
+
+      $this->paginacion = [
+        "total" => $total, # Total de elementos
+        "pagina" => $pagina, # Página actual
+        "limite" => $limite # Elementos por página
+      ];
+
+      $s = new Serializer();
+      $config = [
+        "query" => $modelo, 
+        "pagination" => false,
+      ];
+      if($this->db !== null) {
+        $config["db"] = $this->db;
+      }
+      $this->resultado = $s->serialize(new ActiveDataProvider($config));
+    } elseif(is_array($modelo) && isset($modelo[0])) {
+      $total = count($modelo);
+      $this->paginacion = [
+        "total" => $total,
+        "pagina" => 1,
+        "limite" => $total
+      ];
+      $this->resultado = $modelo;
+    } else {
+      $this->paginacion = [
+        "total" => 1,
+        "pagina" => 1,
+        "limite" => 1
+      ];
+      $this->resultado = [$modelo];
+    }
+    return $this;
+  }
+
+  public function esExitoso($codigo = 200) {
+    \Yii::$app->getResponse()->setStatusCode($codigo);
+    return $this;
+  }
+  
+  public function esError($codigo = 400) {
+    \Yii::$app->getResponse()->setStatusCode($codigo);
+    return $this;
+  }
+
+  public function detalle($detalle) {
+    $this->detalle = $detalle;
+    return $this;
+  }
+
+  public function mensaje($mensaje) {
+    $this->mensaje = $mensaje;
+    return $this;
+  }
+
+  public function getParametros() {
+    return $this->parametros;
+  }
+}

+ 45 - 0
components/rest/JsonController.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace app\components\rest;
+
+use yii\filters\ContentNegotiator;
+use yii\filters\Cors;
+use yii\filters\VerbFilter;
+use yii\rest\Controller;
+use yii\web\Response;
+
+class JsonController extends Controller {
+
+  public $serializer = 'app\components\rest\Serializer';
+
+  public function behaviors() {
+    $behavior = parent::behaviors();
+    $behavior['contentNegotiator'] =  [
+      'class' => ContentNegotiator::className(),
+      'formats' => [
+        'application/json' => Response::FORMAT_JSON,
+        'application/xml' => Response::FORMAT_XML,
+      ],
+    ];
+    $behavior['corsFilter'] = [
+      'class' => Cors::className(),
+      'cors' => [
+        'Origin' => ['*'],
+        'Access-Control-Request-Method' => [
+          'GET', 'POST', 'PUT', 'PATCH', 
+          'DELETE', 'HEAD', 'OPTIONS'
+        ],
+        'Access-Control-Request-Headers' => ['*'],
+      ],
+    ];
+    $behavior["authenticator"]["except"] = ['options'];
+    return $behavior;
+  }
+
+  public function beforeAction($action) {
+    if (!parent::beforeAction($action)) return false;
+    \Yii::$app->getResponse()->format = Response::FORMAT_JSON;
+    return true;
+  }
+
+}

+ 19 - 0
components/rest/Serializer.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace app\components\rest;
+
+use yii\rest\Serializer as YiiSerializer;
+use app\components\data\Respuesta;
+
+class Serializer extends YiiSerializer {
+
+  public function serialize($data) {
+    $data = parent::serialize($data);
+    if ($data instanceof Respuesta) {
+      return $data->cuerpo;
+    }
+
+    return $data;
+  }
+
+}

+ 71 - 0
composer.json

@@ -0,0 +1,71 @@
+{
+    "name": "yiisoft/yii2-app-basic",
+    "description": "Yii 2 Basic Project Template",
+    "keywords": ["yii2", "framework", "basic", "project template"],
+    "homepage": "http://www.yiiframework.com/",
+    "type": "project",
+    "license": "BSD-3-Clause",
+    "support": {
+        "issues": "https://github.com/yiisoft/yii2/issues?state=open",
+        "forum": "http://www.yiiframework.com/forum/",
+        "wiki": "http://www.yiiframework.com/wiki/",
+        "irc": "irc://irc.freenode.net/yii",
+        "source": "https://github.com/yiisoft/yii2"
+    },
+    "minimum-stability": "stable",
+    "require": {
+        "php": ">=5.6.0",
+        "yiisoft/yii2": "~2.0.14",
+        "yiisoft/yii2-bootstrap4": "~2.0.0",
+        "yiisoft/yii2-swiftmailer": "~2.0.0 || ~2.1.0"
+    },
+    "require-dev": {
+        "yiisoft/yii2-debug": "~2.1.0",
+        "yiisoft/yii2-gii": "~2.2.0",
+        "yiisoft/yii2-faker": "~2.0.0",
+        "codeception/codeception": "^4.0",
+        "codeception/verify": "~0.5.0 || ~1.1.0",
+        "codeception/specify": "~0.4.6",
+        "symfony/browser-kit": ">=2.7 <=4.2.4",
+        "codeception/module-filesystem": "^1.0.0",
+        "codeception/module-yii2": "^1.0.0",
+        "codeception/module-asserts": "^1.0.0"
+    },
+    "config": {
+        "process-timeout": 1800,
+        "fxp-asset": {
+            "enabled": false
+        }
+    },
+    "scripts": {
+        "post-install-cmd": [
+            "yii\\composer\\Installer::postInstall"
+        ],
+        "post-create-project-cmd": [
+            "yii\\composer\\Installer::postCreateProject",
+            "yii\\composer\\Installer::postInstall"
+        ]
+    },
+    "extra": {
+        "yii\\composer\\Installer::postCreateProject": {
+            "setPermission": [
+                {
+                    "runtime": "0777",
+                    "web/assets": "0777",
+                    "yii": "0755"
+                }
+            ]
+        },
+        "yii\\composer\\Installer::postInstall": {
+            "generateCookieValidationKey": [
+                "config/web.php"
+            ]
+        }
+    },
+    "repositories": [
+        {
+            "type": "composer",
+            "url": "https://asset-packagist.org"
+        }
+    ]
+}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 5528 - 0
composer.lock


+ 50 - 0
config/console.php

@@ -0,0 +1,50 @@
+<?php
+
+$params = require __DIR__ . '/params.php';
+$db = require __DIR__ . '/db.php';
+$dbr = require __DIR__ . '/dbr.php';
+
+$config = [
+    'id' => 'basic-console',
+    'basePath' => dirname(__DIR__),
+    'bootstrap' => ['log'],
+    'controllerNamespace' => 'app\commands',
+    'aliases' => [
+        '@bower' => '@vendor/bower-asset',
+        '@npm'   => '@vendor/npm-asset',
+        '@tests' => '@app/tests',
+    ],
+    'components' => [
+        'cache' => [
+            'class' => 'yii\caching\FileCache',
+        ],
+        'log' => [
+            'targets' => [
+                [
+                    'class' => 'yii\log\FileTarget',
+                    'levels' => ['error', 'warning'],
+                ],
+            ],
+        ],
+        'db' => $db,
+        'dbr' => $dbr,
+    ],
+    'params' => $params,
+    /*
+    'controllerMap' => [
+        'fixture' => [ // Fixture generation command line.
+            'class' => 'yii\faker\FixtureController',
+        ],
+    ],
+    */
+];
+
+if (YII_ENV_DEV) {
+    // configuration adjustments for 'dev' environment
+    $config['bootstrap'][] = 'gii';
+    $config['modules']['gii'] = [
+        'class' => 'yii\gii\Module',
+    ];
+}
+
+return $config;

+ 12 - 0
config/db.php

@@ -0,0 +1,12 @@
+<?php
+
+return [
+  'class' => 'yii\db\Connection',
+  'dsn' => 'pgsql:dbname=fourier;host=172.30.241.37;port=5432',
+  'username' => 'fourier',
+  'password' => 'NOlav3as',
+  'charset' => 'utf8',
+  'enableSchemaCache' => true,
+  'schemaCacheDuration' => 60,
+  'schemaCache' => 'cache',
+];

+ 12 - 0
config/dbr.php

@@ -0,0 +1,12 @@
+<?php
+
+return [
+  'class' => 'yii\db\Connection',
+  'dsn' => 'pgsql:dbname=fourier;host=172.30.241.29;port=5432',
+  'username' => 'fourier',
+  'password' => 'NOlav3as',
+  'charset' => 'utf8',
+  'enableSchemaCache' => true,
+  'schemaCacheDuration' => 60,
+  'schemaCache' => 'cache',
+];

+ 7 - 0
config/params.php

@@ -0,0 +1,7 @@
+<?php
+
+return [
+    'adminEmail' => 'admin@example.com',
+    'senderEmail' => 'noreply@example.com',
+    'senderName' => 'Example.com mailer',
+];

+ 42 - 0
config/test.php

@@ -0,0 +1,42 @@
+<?php
+$params = require __DIR__ . '/params.php';
+$db = require __DIR__ . '/test_db.php';
+
+/**
+ * Application configuration shared by all test types
+ */
+return [
+    'id' => 'basic-tests',
+    'basePath' => dirname(__DIR__),
+    'aliases' => [
+        '@bower' => '@vendor/bower-asset',
+        '@npm'   => '@vendor/npm-asset',
+    ],
+    'language' => 'en-US',
+    'components' => [
+        'db' => $db,
+        'mailer' => [
+            'useFileTransport' => true,
+        ],
+        'assetManager' => [
+            'basePath' => __DIR__ . '/../web/assets',
+        ],
+        'urlManager' => [
+            'showScriptName' => true,
+        ],
+        'user' => [
+            'identityClass' => 'app\models\User',
+        ],
+        'request' => [
+            'cookieValidationKey' => 'test',
+            'enableCsrfValidation' => false,
+            // but if you absolutely need it set cookie domain to localhost
+            /*
+            'csrfCookie' => [
+                'domain' => 'localhost',
+            ],
+            */
+        ],
+    ],
+    'params' => $params,
+];

+ 6 - 0
config/test_db.php

@@ -0,0 +1,6 @@
+<?php
+$db = require __DIR__ . '/db.php';
+// test database! Important not to run tests on production or development databases
+$db['dsn'] = 'mysql:host=localhost;dbname=yii2basic_test';
+
+return $db;

+ 78 - 0
config/web.php

@@ -0,0 +1,78 @@
+<?php
+
+$params = require __DIR__ . '/params.php';
+$db = require __DIR__ . '/db.php';
+$dbr = require __DIR__ . '/dbr.php';
+
+$config = [
+  'id' => 'basic',
+  'basePath' => dirname(__DIR__),
+  'bootstrap' => ['log'],
+  'aliases' => [
+    '@bower' => '@vendor/bower-asset',
+    '@npm'   => '@vendor/npm-asset',
+  ],
+  'components' => [
+    'request' => [
+      'cookieValidationKey' => 'H-D9NgQ8byEDjH6lIcBmeZMYcKGVprZE',
+      'parsers' => [
+        'application/json' => 'yii\web\JsonParser',
+      ]
+    ],
+    'cache' => [
+      'class' => 'yii\caching\FileCache',
+    ],
+    'user' => [
+      'identityClass' => 'app\models\Usuario',
+      'enableAutoLogin' => true,
+      'loginUrl' => 'login'
+    ],
+    'errorHandler' => [
+      'errorAction' => 'site/error',
+    ],
+    'mailer' => [
+      'class' => 'yii\swiftmailer\Mailer',
+      // send all mails to a file by default. You have to set
+      // 'useFileTransport' to false and configure a transport
+      // for the mailer to send real emails.
+      'useFileTransport' => true,
+    ],
+    'log' => [
+      'traceLevel' => YII_DEBUG ? 3 : 0,
+      'targets' => [
+        [
+          'class' => 'yii\log\FileTarget',
+          'levels' => ['error', 'warning'],
+        ],
+      ],
+    ],
+    'db' => $db,
+    'dbr' => $dbr,
+    'urlManager' => [
+      'enablePrettyUrl' => true,
+      'showScriptName' => false,
+      'rules' => [
+      ],
+    ],
+  ],
+  'params' => $params,
+];
+
+if (YII_ENV_DEV) {
+    // configuration adjustments for 'dev' environment
+    $config['bootstrap'][] = 'debug';
+    $config['modules']['debug'] = [
+      'class' => 'yii\debug\Module',
+      // uncomment the following to add your IP if you are not connecting from localhost.
+      //'allowedIPs' => ['127.0.0.1', '::1'],
+    ];
+
+    $config['bootstrap'][] = 'gii';
+    $config['modules']['gii'] = [
+      'class' => 'yii\gii\Module',
+      // uncomment the following to add your IP if you are not connecting from localhost.
+      //'allowedIPs' => ['127.0.0.1', '::1'],
+    ];
+}
+
+return $config;

+ 112 - 0
controllers/DescargaController.php

@@ -0,0 +1,112 @@
+<?php
+
+namespace app\controllers;
+
+use app\components\data\Respuesta;
+use app\components\rest\JsonController;
+use yii\db\Query;
+use yii\filters\AccessControl;
+use yii\filters\VerbFilter;
+
+class DescargaController extends JsonController {
+
+  public function behaviors() {
+    return [
+      'access' => [
+        'class' => AccessControl::className(),
+        'only' => ['por-pc', 'guardar'],
+        'rules' => [
+          [
+            'actions' => ['por-pc', 'guardar'],
+            'allow' => true,
+            'roles' => ['@'],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  public function actionPorPc() {
+    $req = \Yii::$app->getRequest();
+    $pc = trim($req->get("pc", ""));
+    $fi = trim($req->get("fi", ""));
+    $ff = trim($req->get("ff", ""));
+
+    $query = (new Query())
+      ->select([
+        "{{Estacion}}.id",
+        "{{Estacion}}.clave",
+        "{{Estacion}}.siglas",
+        "{{Estacion}}.frecuencia",
+        "ciudad",
+        "to_char(fecha, 'YYYY-MM-DD') as fecha",
+        "count(estacion) filter (where descargado = true) as descargados",
+        "count(estacion) filter (where descargado = false) as pendientes"
+      ])
+      ->from("Descarga")
+      ->innerJoin("Estacion", "{{Estacion}}.clave = {{Descarga}}.estacion")
+      ->andWhere(["pc" => $pc])
+      ->groupBy(["{{Estacion}}.id", "to_char(fecha, 'YYYY-MM-DD')", "ciudad"])
+      ->orderBy(["fecha" => SORT_DESC]);
+
+      if($fi !== "") {
+        $query->andWhere([">=", "fecha", $fi]);
+      }
+
+      if($ff !== "") {
+        $query->andWhere(["<=", "fecha", $ff]);
+      }
+
+    return (new Respuesta($query, -1));
+  }
+
+  public function actionGuardar() {
+    $req = \Yii::$app->getRequest();
+    $pc = trim($req->getBodyParam("idPc", ""));
+    $fi = trim($req->getBodyParam("fi", ""));
+    $ff = trim($req->getBodyParam("ff", ""));
+    $reiniciar = $req->getBodyParam("reiniciar", false);
+    $estaciones = $req->getBodyParam("estaciones", []);
+
+    $aux = [];
+    foreach($estaciones as $e) {
+      if(!isset($aux[$e["idCiudad"]])) {
+        $aux[$e["idCiudad"]] = [];
+      }
+      $aux[$e["idCiudad"]][] = $e["clave"];
+    }
+    $estaciones = $aux;
+
+    try {
+      $condicion = ["AND",  
+        ["<=", "fecha", $ff], 
+        [">=", "fecha", $fi]
+      ];
+      $aux = ["OR"];
+      foreach($estaciones as $idCiudad => $est) {
+        $aux[] = [
+          "AND", 
+          ["ciudad" => $idCiudad], 
+          ["estacion" => $est]
+        ];
+      }
+      $condicion[] = $aux;
+      $params = ["pc" => $pc];
+      if($reiniciar) {
+        $params["descargado"] = false;
+      }
+      $resultado = \Yii::$app->getDb()->createCommand()
+        ->update("Descarga", $params, $condicion)
+        ->execute();
+
+      return (new Respuesta())
+        ->detalle([ "actualizados" => $resultado ])
+        ->mensaje("OK");
+    } catch(\Exception $e) {
+      return (new Respuesta())
+        ->esError()
+        ->mensaje($e->getMessage());
+    }
+  }
+
+}

+ 51 - 0
controllers/EstacionController.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace app\controllers;
+
+use app\components\data\Respuesta;
+use app\components\rest\JsonController;
+use yii\db\Query;
+use yii\filters\AccessControl;
+
+class EstacionController extends JsonController {
+
+  public function behaviors() {
+    return [
+      'access' => [
+        'class' => AccessControl::className(),
+        'only' => ['por-pc'],
+        'rules' => [
+          [
+            'actions' => ['por-pc'],
+            'allow' => true,
+            'roles' => ['@'],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  public function actionPorPc() {
+    $req = \Yii::$app->getRequest();
+    $pc = trim($req->get("pc", ""));
+
+    if($pc === "") {
+      return (new Respuesta())
+        ->esError()
+        ->mensaje("Debe enviar el PC");
+    }
+
+    $query = (new Query())
+      ->distinct()
+      ->select([
+        "estacion as [[clave]]",
+        "ciudad as [[idCiudad]]",
+        "ciudad || '-' || estacion as [[key]]",
+      ])
+      ->from("Descarga")
+      ->andWhere(["pc" => $pc]);
+
+    return (new Respuesta($query, -1));
+  }
+
+}

+ 29 - 0
controllers/InicioController.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace app\controllers;
+
+use yii\filters\AccessControl;
+
+class InicioController extends \yii\web\Controller {
+
+  public function behaviors() {
+    return [
+      'access' => [
+        'class' => AccessControl::className(),
+        'only' => ['index'],
+        'rules' => [
+          [
+            'actions' => ['index'],
+            'allow' => true,
+            'roles' => ['@'],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  public function actionIndex() {
+    return $this->render('index');
+  }
+
+}

+ 33 - 0
controllers/LoginController.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace app\controllers;
+
+use app\models\Login;
+use Yii;
+
+class LoginController extends \yii\web\Controller {
+
+  public function beforeAction($action) {
+    Yii::$app->getRequest()->enableCsrfValidation = false;
+    return parent::beforeAction($action);
+  }
+
+  public function actionIndex() {
+    if(\Yii::$app->getRequest()->isPost) {
+      $model = new Login();
+      if ($model->load(Yii::$app->request->getBodyParams(), '') && $model->login()) {
+        \Yii::$app->getResponse()->format = \yii\web\Response::FORMAT_JSON;
+        return ['success' => true];
+      }
+
+      return ['success' => false, 'errors' => $model->getErrors()];
+    }
+
+    if(!\Yii::$app->user->isGuest) {
+      return $this->redirect('inicio');
+    }
+
+    return $this->render('index');
+  }
+
+}

+ 59 - 0
controllers/PorPcController.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace app\controllers;
+
+use app\components\data\Respuesta;
+use app\components\rest\JsonController;
+use app\models\Descarga;
+use yii\filters\AccessControl;
+
+class PorPcController extends JsonController {
+
+  public function actionIndex() {
+    $req = \Yii::$app->getRequest();
+    $pc = trim($req->get("pc", ""));
+    $id = intval($req->get("id", ""));
+    $limite = intval($req->get("limite", 1000));
+    $pagina = intval($req->get("pagina", 0));
+    $descargado = intval($req->get("descargado", "")) === 1;
+    if(trim($pc) === "") {
+      return (new Respuesta())
+        ->esError()
+        ->mensaje("el parámetro pc es obligatorio");
+    }
+    $query = Descarga::find()
+      ->andWhere([
+        "pc" => $pc,
+        "descargado" => $descargado
+      ])
+      ->orderBy(["fecha" => SORT_DESC])
+    ;
+    if($id > 0) {
+      $query->andWhere(["id" => $id]);
+    }
+    return (new Respuesta($query, $limite, $pagina));
+  }
+
+  public function actionDescargado() {
+    $req = \Yii::$app->getRequest();
+    $id = $req->getBodyParam("id", []);
+    $pc = trim($req->getBodyParam("pc", ""));
+    if(empty($id) || $pc === "") {
+      return (new Respuesta())
+        ->esError()
+        ->mensaje("Los parámetros id y pc son obligatorios");
+    }
+
+    $rows = Descarga::updateAll(
+      ["descargado" => true],
+      ["pc" => $pc, "id" => $id]);
+
+    return (new Respuesta())
+      ->esExitoso()
+      ->detalle([
+        "actualizados" => $rows
+      ])
+      ->mensaje("{$rows} descargas actualizadas");
+  }
+
+}

+ 67 - 0
controllers/SiteController.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace app\controllers;
+
+use yii\filters\AccessControl;
+use yii\web\Controller;
+use yii\filters\VerbFilter;
+
+class SiteController extends Controller {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function behaviors() {
+    return [
+      'access' => [
+        'class' => AccessControl::className(),
+        'only' => ['asignar-descarga', 'index', 'consulta', 'logout'],
+        'rules' => [
+          [
+            'actions' => ['asignar-descarga', 'index', 'consulta', 'logout'],
+            'allow' => true,
+            'roles' => ['@'],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function actions() {
+    return [
+      'error' => [
+        'class' => 'yii\web\ErrorAction',
+      ],
+      'captcha' => [
+        'class' => 'yii\captcha\CaptchaAction',
+        'fixedVerifyCode' => YII_ENV_TEST ? 'testme' : null,
+      ],
+    ];
+  }
+
+  public function actionLogout() {
+    \Yii::$app->getUser()->logout();
+    return $this->redirect("/");
+  }
+
+  /**
+   * Displays homepage.
+   *
+   * @return string
+   */
+  public function actionIndex() {
+    return $this->redirect('inicio');
+  }
+
+  public function actionAsignarDescarga() {
+    return $this->render('asignar-descarga');
+  }
+
+  public function actionConsulta() {
+    return $this->render('consulta');
+  }
+
+}

+ 9 - 0
docker-compose.yml

@@ -0,0 +1,9 @@
+version: '2'
+services:
+  php:
+    image: yiisoftware/yii2-php:7.4-apache
+    volumes:
+      - ~/.composer-docker/cache:/root/.composer/cache:delegated
+      - ./:/app:delegated
+    ports:
+      - '8000:80'

+ 22 - 0
mail/layouts/html.php

@@ -0,0 +1,22 @@
+<?php
+use yii\helpers\Html;
+
+/* @var $this \yii\web\View view component instance */
+/* @var $message \yii\mail\MessageInterface the message being composed */
+/* @var $content string main view render result */
+?>
+<?php $this->beginPage() ?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+    <meta http-equiv="Content-Type" content="text/html; charset=<?= Yii::$app->charset ?>" />
+    <title><?= Html::encode($this->title) ?></title>
+    <?php $this->head() ?>
+</head>
+<body>
+    <?php $this->beginBody() ?>
+    <?= $content ?>
+    <?php $this->endBody() ?>
+</body>
+</html>
+<?php $this->endPage() ?>

+ 41 - 0
migrations/m211215_012217_inicio.php

@@ -0,0 +1,41 @@
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Class m211215_012217_inicio
+ */
+class m211215_012217_inicio extends Migration {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function safeUp() {
+    $this->createTable("Descarga", [
+      "id" => $this->primaryKey(),
+      "hash" => $this->string(32),
+      "estacion" => $this->string(20),
+      "archivo" => $this->string(100),
+      "ciudad" => $this->string(20),
+      "pc" => $this->string(20),
+      "fecha" => $this->timestamp(),
+      "descargado" => $this->boolean()->defaultValue(false),
+      "nombre" => $this->string(100),
+      "ruta" => $this->string(100)
+    ]);
+
+    $this->createIndex("DescargaHashIndex", "Descarga", "hash");
+    $this->createIndex("DescargaEstacionIndex", "Descarga", "estacion");
+    $this->createIndex("DescargaCiudadIndex", "Descarga", "ciudad");
+    $this->createIndex("DescargaFechaIndex", "Descarga", "fecha");
+    $this->createIndex("DescargaPcIndex", "Descarga", "pc");
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function safeDown() {
+    $this->dropTable("Descarga");
+  }
+
+}

+ 62 - 0
models/Descarga.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace app\models;
+
+use Yii;
+
+/**
+ * This is the model class for table "Descarga".
+ *
+ * @property int $id
+ * @property string|null $hash
+ * @property string|null $estacion
+ * @property string|null $archivo
+ * @property string|null $ciudad
+ * @property string|null $pc
+ * @property string|null $fecha
+ * @property bool|null $descargado
+ * @property string|null $nombre
+ * @property string|null $ruta
+ */
+class Descarga extends \yii\db\ActiveRecord {  
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function tableName() {
+    return 'Descarga';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rules() {
+    return [
+      [['fecha'], 'safe'],
+      [['descargado'], 'boolean'],
+      [['hash'], 'string', 'max' => 32],
+      [['estacion'], 'string', 'max' => 30],
+      [['ciudad', 'pc'], 'string', 'max' => 20],
+      [['archivo', 'nombre', 'ruta'], 'string', 'max' => 100],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function attributeLabels() {
+    return [
+      'id' => 'ID',
+      'hash' => 'Hash',
+      'estacion' => 'Estacion',
+      'archivo' => 'Archivo',
+      'ciudad' => 'Ciudad',
+      'pc' => 'Pc',
+      'fecha' => 'Fecha',
+      'descargado' => 'Descargado',
+      'nombre' => 'Nombre',
+      'ruta' => 'Ruta',
+    ];
+  }
+
+}

+ 65 - 0
models/Login.php

@@ -0,0 +1,65 @@
+<?php
+
+namespace app\models;
+
+use Yii;
+use yii\base\Model;
+
+class Login extends Model {
+
+  public $usuario;
+  public $clave;
+
+  private $_user = false;
+
+  /**
+   * @return array the validation rules.
+   */
+  public function rules() {
+    return [
+      [['usuario', 'clave'], 'required'],
+      ['clave', 'validatePassword'],
+    ];
+  }
+
+  /**
+   * Validates the password.
+   * This method serves as the inline validation for password.
+   *
+   * @param string $attribute the attribute currently being validated
+   * @param array $params the additional name-value pairs given in the rule
+   */
+  public function validatePassword($attribute, $params) {
+    if (!$this->hasErrors()) {
+      $user = $this->getUser();
+
+      if (!$user || !$user->validatePassword($this->clave)) {
+          $this->addError($attribute, 'Incorrect username or password.');
+      }
+    }
+  }
+
+  /**
+   * Logs in a user using the provided username and password.
+   * @return bool whether the user is logged in successfully
+   */
+  public function login() {
+    if ($this->validate()) {
+      return Yii::$app->user->login($this->getUser(), 3600 * 24 * 30);
+    }
+    return false;
+  }
+
+  /**
+   * Finds user by [[username]]
+   *
+   * @return Usuario|null
+   */
+  public function getUser() {
+    if ($this->_user === false) {
+      $this->_user = Usuario::findByUsername($this->usuario);
+    }
+
+    return $this->_user;
+  }
+}

+ 90 - 0
models/Usuario.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace app\models;
+
+class Usuario extends \yii\base\BaseObject implements \yii\web\IdentityInterface {
+
+  public $id;
+  public $username;
+  public $password;
+  public $authKey;
+  public $accessToken;
+
+  private static $users = [
+    '100' => [
+      'id' => '100',
+      'username' => 'admin@audiovalid.com',
+      'password' => 'AudioValid2021',
+      'authKey' => 'test100key',
+      'accessToken' => '100-token',
+    ],
+  ];
+
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function findIdentity($id) {
+    return isset(self::$users[$id]) ? new static(self::$users[$id]) : null;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function findIdentityByAccessToken($token, $type = null) {
+    foreach (self::$users as $user) {
+      if ($user['accessToken'] === $token) {
+        return new static($user);
+      }
+    }
+
+    return null;
+  }
+
+  /**
+   * Finds user by username
+   *
+   * @param string $username
+   * @return static|null
+   */
+  public static function findByUsername($username) {
+    foreach (self::$users as $user) {
+      if (strcasecmp($user['username'], $username) === 0) {
+        return new static($user);
+      }
+    }
+
+    return null;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getId() {
+    return $this->id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAuthKey() {
+    return $this->authKey;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateAuthKey($authKey) {
+    return $this->authKey === $authKey;
+  }
+
+  /**
+   * Validates password
+   *
+   * @param string $password password to validate
+   * @return bool if password provided is valid for current user
+   */
+  public function validatePassword($password) {
+    return $this->password === $password;
+  }
+}

+ 162 - 0
requirements.php

@@ -0,0 +1,162 @@
+<?php
+/**
+ * Application requirement checker script.
+ *
+ * In order to run this script use the following console command:
+ * php requirements.php
+ *
+ * In order to run this script from the web, you should copy it to the web root.
+ * If you are using Linux you can create a hard link instead, using the following command:
+ * ln ../requirements.php requirements.php
+ */
+
+// you may need to adjust this path to the correct Yii framework path
+// uncomment and adjust the following line if Yii is not located at the default path
+//$frameworkPath = dirname(__FILE__) . '/vendor/yiisoft/yii2';
+
+
+if (!isset($frameworkPath)) {
+    $searchPaths = array(
+        dirname(__FILE__) . '/vendor/yiisoft/yii2',
+        dirname(__FILE__) . '/../vendor/yiisoft/yii2',
+    );
+    foreach ($searchPaths as $path) {
+        if (is_dir($path)) {
+            $frameworkPath = $path;
+            break;
+        }
+    }
+}
+
+if (!isset($frameworkPath) || !is_dir($frameworkPath)) {
+    $message = "<h1>Error</h1>\n\n"
+        . "<p><strong>The path to yii framework seems to be incorrect.</strong></p>\n"
+        . '<p>You need to install Yii framework via composer or adjust the framework path in file <abbr title="' . __FILE__ . '">' . basename(__FILE__) . "</abbr>.</p>\n"
+        . '<p>Please refer to the <abbr title="' . dirname(__FILE__) . "/README.md\">README</abbr> on how to install Yii.</p>\n";
+
+    if (!empty($_SERVER['argv'])) {
+        // do not print HTML when used in console mode
+        echo strip_tags($message);
+    } else {
+        echo $message;
+    }
+    exit(1);
+}
+
+require_once($frameworkPath . '/requirements/YiiRequirementChecker.php');
+$requirementsChecker = new YiiRequirementChecker();
+
+$gdMemo = $imagickMemo = 'Either GD PHP extension with FreeType support or ImageMagick PHP extension with PNG support is required for image CAPTCHA.';
+$gdOK = $imagickOK = false;
+
+if (extension_loaded('imagick')) {
+    $imagick = new Imagick();
+    $imagickFormats = $imagick->queryFormats('PNG');
+    if (in_array('PNG', $imagickFormats)) {
+        $imagickOK = true;
+    } else {
+        $imagickMemo = 'Imagick extension should be installed with PNG support in order to be used for image CAPTCHA.';
+    }
+}
+
+if (extension_loaded('gd')) {
+    $gdInfo = gd_info();
+    if (!empty($gdInfo['FreeType Support'])) {
+        $gdOK = true;
+    } else {
+        $gdMemo = 'GD extension should be installed with FreeType support in order to be used for image CAPTCHA.';
+    }
+}
+
+/**
+ * Adjust requirements according to your application specifics.
+ */
+$requirements = array(
+    // Database :
+    array(
+        'name' => 'PDO extension',
+        'mandatory' => true,
+        'condition' => extension_loaded('pdo'),
+        'by' => 'All DB-related classes',
+    ),
+    array(
+        'name' => 'PDO SQLite extension',
+        'mandatory' => false,
+        'condition' => extension_loaded('pdo_sqlite'),
+        'by' => 'All DB-related classes',
+        'memo' => 'Required for SQLite database.',
+    ),
+    array(
+        'name' => 'PDO MySQL extension',
+        'mandatory' => false,
+        'condition' => extension_loaded('pdo_mysql'),
+        'by' => 'All DB-related classes',
+        'memo' => 'Required for MySQL database.',
+    ),
+    array(
+        'name' => 'PDO PostgreSQL extension',
+        'mandatory' => false,
+        'condition' => extension_loaded('pdo_pgsql'),
+        'by' => 'All DB-related classes',
+        'memo' => 'Required for PostgreSQL database.',
+    ),
+    // Cache :
+    array(
+        'name' => 'Memcache extension',
+        'mandatory' => false,
+        'condition' => extension_loaded('memcache') || extension_loaded('memcached'),
+        'by' => '<a href="http://www.yiiframework.com/doc-2.0/yii-caching-memcache.html">MemCache</a>',
+        'memo' => extension_loaded('memcached') ? 'To use memcached set <a href="http://www.yiiframework.com/doc-2.0/yii-caching-memcache.html#$useMemcached-detail">MemCache::useMemcached</a> to <code>true</code>.' : ''
+    ),
+    // CAPTCHA:
+    array(
+        'name' => 'GD PHP extension with FreeType support',
+        'mandatory' => false,
+        'condition' => $gdOK,
+        'by' => '<a href="http://www.yiiframework.com/doc-2.0/yii-captcha-captcha.html">Captcha</a>',
+        'memo' => $gdMemo,
+    ),
+    array(
+        'name' => 'ImageMagick PHP extension with PNG support',
+        'mandatory' => false,
+        'condition' => $imagickOK,
+        'by' => '<a href="http://www.yiiframework.com/doc-2.0/yii-captcha-captcha.html">Captcha</a>',
+        'memo' => $imagickMemo,
+    ),
+    // PHP ini :
+    'phpExposePhp' => array(
+        'name' => 'Expose PHP',
+        'mandatory' => false,
+        'condition' => $requirementsChecker->checkPhpIniOff("expose_php"),
+        'by' => 'Security reasons',
+        'memo' => '"expose_php" should be disabled at php.ini',
+    ),
+    'phpAllowUrlInclude' => array(
+        'name' => 'PHP allow url include',
+        'mandatory' => false,
+        'condition' => $requirementsChecker->checkPhpIniOff("allow_url_include"),
+        'by' => 'Security reasons',
+        'memo' => '"allow_url_include" should be disabled at php.ini',
+    ),
+    'phpSmtp' => array(
+        'name' => 'PHP mail SMTP',
+        'mandatory' => false,
+        'condition' => strlen(ini_get('SMTP')) > 0,
+        'by' => 'Email sending',
+        'memo' => 'PHP mail SMTP server required',
+    ),
+);
+
+// OPcache check
+if (!version_compare(phpversion(), '5.5', '>=')) {
+    $requirements[] = array(
+        'name' => 'APC extension',
+        'mandatory' => false,
+        'condition' => extension_loaded('apc'),
+        'by' => '<a href="http://www.yiiframework.com/doc-2.0/yii-caching-apccache.html">ApcCache</a>',
+    );
+}
+
+$result = $requirementsChecker->checkYii()->check($requirements)->getResult();
+$requirementsChecker->render();
+exit($result['summary']['errors'] === 0 ? 0 : 1);

+ 2 - 0
runtime/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 6 - 0
tests/_bootstrap.php

@@ -0,0 +1,6 @@
+<?php
+define('YII_ENV', 'test');
+defined('YII_DEBUG') or define('YII_DEBUG', true);
+
+require_once __DIR__ . '/../vendor/yiisoft/yii2/Yii.php';
+require __DIR__ .'/../vendor/autoload.php';

+ 1 - 0
tests/_data/.gitkeep

@@ -0,0 +1 @@
+

+ 2 - 0
tests/_output/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 26 - 0
tests/_support/AcceptanceTester.php

@@ -0,0 +1,26 @@
+<?php
+
+
+/**
+ * Inherited Methods
+ * @method void wantToTest($text)
+ * @method void wantTo($text)
+ * @method void execute($callable)
+ * @method void expectTo($prediction)
+ * @method void expect($prediction)
+ * @method void amGoingTo($argumentation)
+ * @method void am($role)
+ * @method void lookForwardTo($achieveValue)
+ * @method void comment($description)
+ * @method \Codeception\Lib\Friend haveFriend($name, $actorClass = NULL)
+ *
+ * @SuppressWarnings(PHPMD)
+*/
+class AcceptanceTester extends \Codeception\Actor
+{
+    use _generated\AcceptanceTesterActions;
+
+   /**
+    * Define custom actions here
+    */
+}

+ 23 - 0
tests/_support/FunctionalTester.php

@@ -0,0 +1,23 @@
+<?php
+
+
+/**
+ * Inherited Methods
+ * @method void wantToTest($text)
+ * @method void wantTo($text)
+ * @method void execute($callable)
+ * @method void expectTo($prediction)
+ * @method void expect($prediction)
+ * @method void amGoingTo($argumentation)
+ * @method void am($role)
+ * @method void lookForwardTo($achieveValue)
+ * @method void comment($description)
+ * @method \Codeception\Lib\Friend haveFriend($name, $actorClass = NULL)
+ *
+ * @SuppressWarnings(PHPMD)
+*/
+class FunctionalTester extends \Codeception\Actor
+{
+    use _generated\FunctionalTesterActions;
+
+}

+ 26 - 0
tests/_support/UnitTester.php

@@ -0,0 +1,26 @@
+<?php
+
+
+/**
+ * Inherited Methods
+ * @method void wantToTest($text)
+ * @method void wantTo($text)
+ * @method void execute($callable)
+ * @method void expectTo($prediction)
+ * @method void expect($prediction)
+ * @method void amGoingTo($argumentation)
+ * @method void am($role)
+ * @method void lookForwardTo($achieveValue)
+ * @method void comment($description)
+ * @method \Codeception\Lib\Friend haveFriend($name, $actorClass = NULL)
+ *
+ * @SuppressWarnings(PHPMD)
+*/
+class UnitTester extends \Codeception\Actor
+{
+    use _generated\UnitTesterActions;
+
+   /**
+    * Define custom actions here
+    */
+}

+ 10 - 0
tests/acceptance.suite.yml.example

@@ -0,0 +1,10 @@
+class_name: AcceptanceTester
+modules:
+    enabled:
+        - WebDriver:
+            url: http://127.0.0.1:8080/
+            browser: firefox
+        - Yii2:
+            part: orm
+            entryScript: index-test.php
+            cleanup: false

+ 12 - 0
tests/acceptance/AboutCest.php

@@ -0,0 +1,12 @@
+<?php
+
+use yii\helpers\Url;
+
+class AboutCest
+{
+    public function ensureThatAboutWorks(AcceptanceTester $I)
+    {
+        $I->amOnPage(Url::toRoute('/site/about'));
+        $I->see('About', 'h1');
+    }
+}

+ 34 - 0
tests/acceptance/ContactCest.php

@@ -0,0 +1,34 @@
+<?php
+
+use yii\helpers\Url;
+
+class ContactCest
+{
+    public function _before(\AcceptanceTester $I)
+    {
+        $I->amOnPage(Url::toRoute('/site/contact'));
+    }
+    
+    public function contactPageWorks(AcceptanceTester $I)
+    {
+        $I->wantTo('ensure that contact page works');
+        $I->see('Contact', 'h1');
+    }
+
+    public function contactFormCanBeSubmitted(AcceptanceTester $I)
+    {
+        $I->amGoingTo('submit contact form with correct data');
+        $I->fillField('#contactform-name', 'tester');
+        $I->fillField('#contactform-email', 'tester@example.com');
+        $I->fillField('#contactform-subject', 'test subject');
+        $I->fillField('#contactform-body', 'test content');
+        $I->fillField('#contactform-verifycode', 'testme');
+
+        $I->click('contact-button');
+        
+        $I->wait(2); // wait for button to be clicked
+
+        $I->dontSeeElement('#contact-form');
+        $I->see('Thank you for contacting us. We will respond to you as soon as possible.');
+    }
+}

+ 18 - 0
tests/acceptance/HomeCest.php

@@ -0,0 +1,18 @@
+<?php
+
+use yii\helpers\Url;
+
+class HomeCest
+{
+    public function ensureThatHomePageWorks(AcceptanceTester $I)
+    {
+        $I->amOnPage(Url::toRoute('/site/index'));        
+        $I->see('My Company');
+        
+        $I->seeLink('About');
+        $I->click('About');
+        $I->wait(2); // wait for page to be opened
+        
+        $I->see('This is the About page.');
+    }
+}

+ 21 - 0
tests/acceptance/LoginCest.php

@@ -0,0 +1,21 @@
+<?php
+
+use yii\helpers\Url;
+
+class LoginCest
+{
+    public function ensureThatLoginWorks(AcceptanceTester $I)
+    {
+        $I->amOnPage(Url::toRoute('/site/login'));
+        $I->see('Login', 'h1');
+
+        $I->amGoingTo('try to login with correct credentials');
+        $I->fillField('input[name="LoginForm[username]"]', 'admin');
+        $I->fillField('input[name="LoginForm[password]"]', 'admin');
+        $I->click('login-button');
+        $I->wait(2); // wait for button to be clicked
+
+        $I->expectTo('see user info');
+        $I->see('Logout');
+    }
+}

+ 1 - 0
tests/acceptance/_bootstrap.php

@@ -0,0 +1 @@
+<?php

+ 29 - 0
tests/bin/yii

@@ -0,0 +1,29 @@
+#!/usr/bin/env php
+<?php
+/**
+ * Yii console bootstrap file.
+ *
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright (c) 2008 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+defined('YII_DEBUG') or define('YII_DEBUG', true);
+defined('YII_ENV') or define('YII_ENV', 'test');
+
+require __DIR__ . '/../../vendor/autoload.php';
+require __DIR__ . '/../../vendor/yiisoft/yii2/Yii.php';
+
+$config = yii\helpers\ArrayHelper::merge(
+    require __DIR__ . '/../../config/console.php',
+    [
+        'components' => [
+            'db' => require __DIR__ . '/../../config/test_db.php'
+        ]
+    ]
+);
+
+
+$application = new yii\console\Application($config);
+$exitCode = $application->run();
+exit($exitCode);

+ 20 - 0
tests/bin/yii.bat

@@ -0,0 +1,20 @@
+@echo off
+
+rem -------------------------------------------------------------
+rem  Yii command line bootstrap script for Windows.
+rem
+rem  @author Qiang Xue <qiang.xue@gmail.com>
+rem  @link http://www.yiiframework.com/
+rem  @copyright Copyright (c) 2008 Yii Software LLC
+rem  @license http://www.yiiframework.com/license/
+rem -------------------------------------------------------------
+
+@setlocal
+
+set YII_PATH=%~dp0
+
+if "%PHP_COMMAND%" == "" set PHP_COMMAND=php.exe
+
+"%PHP_COMMAND%" "%YII_PATH%yii" %*
+
+@endlocal

+ 13 - 0
tests/functional.suite.yml

@@ -0,0 +1,13 @@
+# Codeception Test Suite Configuration
+
+# suite for functional (integration) tests.
+# emulate web requests and make application process them.
+# (tip: better to use with frameworks).
+
+# RUN `build` COMMAND AFTER ADDING/REMOVING MODULES.
+#basic/web/index.php
+class_name: FunctionalTester
+modules:
+    enabled:
+      - Filesystem
+      - Yii2

+ 57 - 0
tests/functional/ContactFormCest.php

@@ -0,0 +1,57 @@
+<?php
+
+class ContactFormCest 
+{
+    public function _before(\FunctionalTester $I)
+    {
+        $I->amOnPage(['site/contact']);
+    }
+
+    public function openContactPage(\FunctionalTester $I)
+    {
+        $I->see('Contact', 'h1');        
+    }
+
+    public function submitEmptyForm(\FunctionalTester $I)
+    {
+        $I->submitForm('#contact-form', []);
+        $I->expectTo('see validations errors');
+        $I->see('Contact', 'h1');
+        $I->see('Name cannot be blank');
+        $I->see('Email cannot be blank');
+        $I->see('Subject cannot be blank');
+        $I->see('Body cannot be blank');
+        $I->see('The verification code is incorrect');
+    }
+
+    public function submitFormWithIncorrectEmail(\FunctionalTester $I)
+    {
+        $I->submitForm('#contact-form', [
+            'ContactForm[name]' => 'tester',
+            'ContactForm[email]' => 'tester.email',
+            'ContactForm[subject]' => 'test subject',
+            'ContactForm[body]' => 'test content',
+            'ContactForm[verifyCode]' => 'testme',
+        ]);
+        $I->expectTo('see that email address is wrong');
+        $I->dontSee('Name cannot be blank', '.help-inline');
+        $I->see('Email is not a valid email address.');
+        $I->dontSee('Subject cannot be blank', '.help-inline');
+        $I->dontSee('Body cannot be blank', '.help-inline');
+        $I->dontSee('The verification code is incorrect', '.help-inline');        
+    }
+
+    public function submitFormSuccessfully(\FunctionalTester $I)
+    {
+        $I->submitForm('#contact-form', [
+            'ContactForm[name]' => 'tester',
+            'ContactForm[email]' => 'tester@example.com',
+            'ContactForm[subject]' => 'test subject',
+            'ContactForm[body]' => 'test content',
+            'ContactForm[verifyCode]' => 'testme',
+        ]);
+        $I->seeEmailIsSent();
+        $I->dontSeeElement('#contact-form');
+        $I->see('Thank you for contacting us. We will respond to you as soon as possible.');        
+    }
+}

+ 59 - 0
tests/functional/LoginFormCest.php

@@ -0,0 +1,59 @@
+<?php
+
+class LoginFormCest
+{
+    public function _before(\FunctionalTester $I)
+    {
+        $I->amOnRoute('site/login');
+    }
+
+    public function openLoginPage(\FunctionalTester $I)
+    {
+        $I->see('Login', 'h1');
+
+    }
+
+    // demonstrates `amLoggedInAs` method
+    public function internalLoginById(\FunctionalTester $I)
+    {
+        $I->amLoggedInAs(100);
+        $I->amOnPage('/');
+        $I->see('Logout (admin)');
+    }
+
+    // demonstrates `amLoggedInAs` method
+    public function internalLoginByInstance(\FunctionalTester $I)
+    {
+        $I->amLoggedInAs(\app\models\User::findByUsername('admin'));
+        $I->amOnPage('/');
+        $I->see('Logout (admin)');
+    }
+
+    public function loginWithEmptyCredentials(\FunctionalTester $I)
+    {
+        $I->submitForm('#login-form', []);
+        $I->expectTo('see validations errors');
+        $I->see('Username cannot be blank.');
+        $I->see('Password cannot be blank.');
+    }
+
+    public function loginWithWrongCredentials(\FunctionalTester $I)
+    {
+        $I->submitForm('#login-form', [
+            'LoginForm[username]' => 'admin',
+            'LoginForm[password]' => 'wrong',
+        ]);
+        $I->expectTo('see validations errors');
+        $I->see('Incorrect username or password.');
+    }
+
+    public function loginSuccessfully(\FunctionalTester $I)
+    {
+        $I->submitForm('#login-form', [
+            'LoginForm[username]' => 'admin',
+            'LoginForm[password]' => 'admin',
+        ]);
+        $I->see('Logout (admin)');
+        $I->dontSeeElement('form#login-form');              
+    }
+}

+ 1 - 0
tests/functional/_bootstrap.php

@@ -0,0 +1 @@
+<?php

+ 11 - 0
tests/unit.suite.yml

@@ -0,0 +1,11 @@
+# Codeception Test Suite Configuration
+
+# suite for unit (internal) tests.
+# RUN `build` COMMAND AFTER ADDING/REMOVING MODULES.
+
+class_name: UnitTester
+modules:
+    enabled:
+      - Asserts
+      - Yii2:
+            part: [orm, email, fixtures]

+ 3 - 0
tests/unit/_bootstrap.php

@@ -0,0 +1,3 @@
+<?php
+
+// add unit testing specific bootstrap code here

+ 41 - 0
tests/unit/models/ContactFormTest.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace tests\unit\models;
+
+use app\models\ContactForm;
+use yii\mail\MessageInterface;
+
+class ContactFormTest extends \Codeception\Test\Unit
+{
+    /**
+     * @var \UnitTester
+     */
+    public $tester;
+
+    public function testEmailIsSentOnContact()
+    {
+        $model = new ContactForm();
+
+        $model->attributes = [
+            'name' => 'Tester',
+            'email' => 'tester@example.com',
+            'subject' => 'very important letter subject',
+            'body' => 'body of current message',
+            'verifyCode' => 'testme',
+        ];
+
+        expect_that($model->contact('admin@example.com'));
+
+        // using Yii2 module actions to check email was sent
+        $this->tester->seeEmailIsSent();
+
+        /** @var MessageInterface $emailMessage */
+        $emailMessage = $this->tester->grabLastSentEmail();
+        expect('valid email is sent', $emailMessage)->isInstanceOf('yii\mail\MessageInterface');
+        expect($emailMessage->getTo())->hasKey('admin@example.com');
+        expect($emailMessage->getFrom())->hasKey('noreply@example.com');
+        expect($emailMessage->getReplyTo())->hasKey('tester@example.com');
+        expect($emailMessage->getSubject())->equals('very important letter subject');
+        expect($emailMessage->toString())->stringContainsString('body of current message');
+    }
+}

+ 51 - 0
tests/unit/models/LoginFormTest.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace tests\unit\models;
+
+use app\models\LoginForm;
+
+class LoginFormTest extends \Codeception\Test\Unit
+{
+    private $model;
+
+    protected function _after()
+    {
+        \Yii::$app->user->logout();
+    }
+
+    public function testLoginNoUser()
+    {
+        $this->model = new LoginForm([
+            'username' => 'not_existing_username',
+            'password' => 'not_existing_password',
+        ]);
+
+        expect_not($this->model->login());
+        expect_that(\Yii::$app->user->isGuest);
+    }
+
+    public function testLoginWrongPassword()
+    {
+        $this->model = new LoginForm([
+            'username' => 'demo',
+            'password' => 'wrong_password',
+        ]);
+
+        expect_not($this->model->login());
+        expect_that(\Yii::$app->user->isGuest);
+        expect($this->model->errors)->hasKey('password');
+    }
+
+    public function testLoginCorrect()
+    {
+        $this->model = new LoginForm([
+            'username' => 'demo',
+            'password' => 'demo',
+        ]);
+
+        expect_that($this->model->login());
+        expect_not(\Yii::$app->user->isGuest);
+        expect($this->model->errors)->hasntKey('password');
+    }
+
+}

+ 44 - 0
tests/unit/models/UserTest.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace tests\unit\models;
+
+use app\models\User;
+
+class UserTest extends \Codeception\Test\Unit
+{
+    public function testFindUserById()
+    {
+        expect_that($user = User::findIdentity(100));
+        expect($user->username)->equals('admin');
+
+        expect_not(User::findIdentity(999));
+    }
+
+    public function testFindUserByAccessToken()
+    {
+        expect_that($user = User::findIdentityByAccessToken('100-token'));
+        expect($user->username)->equals('admin');
+
+        expect_not(User::findIdentityByAccessToken('non-existing'));        
+    }
+
+    public function testFindUserByUsername()
+    {
+        expect_that($user = User::findByUsername('admin'));
+        expect_not(User::findByUsername('not-admin'));
+    }
+
+    /**
+     * @depends testFindUserByUsername
+     */
+    public function testValidateUser($user)
+    {
+        $user = User::findByUsername('admin');
+        expect_that($user->validateAuthKey('test100key'));
+        expect_not($user->validateAuthKey('test102key'));
+
+        expect_that($user->validatePassword('admin'));
+        expect_not($user->validatePassword('123456'));        
+    }
+
+}

+ 2 - 0
vagrant/config/.gitignore

@@ -0,0 +1,2 @@
+# local configuration
+vagrant-local.yml

+ 22 - 0
vagrant/config/vagrant-local.example.yml

@@ -0,0 +1,22 @@
+# Your personal GitHub token
+github_token: <your-personal-github-token>
+# Read more: https://github.com/blog/1509-personal-api-tokens
+# You can generate it here: https://github.com/settings/tokens
+
+# Guest OS timezone
+timezone: Europe/London
+
+# Are we need check box updates for every 'vagrant up'?
+box_check_update: false
+
+# Virtual machine name
+machine_name: yii2basic
+
+# Virtual machine IP
+ip: 192.168.83.137
+
+# Virtual machine CPU cores number
+cpus: 1
+
+# Virtual machine RAM
+memory: 1024

+ 38 - 0
vagrant/nginx/app.conf

@@ -0,0 +1,38 @@
+server {
+   charset utf-8;
+   client_max_body_size 128M;
+   sendfile off;
+
+   listen 80; ## listen for ipv4
+   #listen [::]:80 default_server ipv6only=on; ## listen for ipv6
+
+   server_name yii2basic.test;
+   root        /app/web/;
+   index       index.php;
+
+   access_log  /app/vagrant/nginx/log/yii2basic.access.log;
+   error_log   /app/vagrant/nginx/log/yii2basic.error.log;
+
+   location / {
+       # Redirect everything that isn't a real file to index.php
+       try_files $uri $uri/ /index.php$is_args$args;
+   }
+
+   # uncomment to avoid processing of calls to non-existing static files by Yii
+   #location ~ \.(js|css|png|jpg|gif|swf|ico|pdf|mov|fla|zip|rar)$ {
+   #    try_files $uri =404;
+   #}
+   #error_page 404 /404.html;
+
+   location ~ \.php$ {
+       include fastcgi_params;
+       fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+       #fastcgi_pass   127.0.0.1:9000;
+       fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
+       try_files $uri =404;
+   }
+
+   location ~ /\.(ht|svn|git) {
+       deny all;
+   }
+}

+ 3 - 0
vagrant/nginx/log/.gitignore

@@ -0,0 +1,3 @@
+#nginx logs
+yii2basic.access.log
+yii2basic.error.log

+ 18 - 0
vagrant/provision/always-as-root.sh

@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+
+#== Bash helpers ==
+
+function info {
+  echo " "
+  echo "--> $1"
+  echo " "
+}
+
+#== Provision script ==
+
+info "Provision-script user: `whoami`"
+
+info "Restart web-stack"
+service php7.2-fpm restart
+service nginx restart
+service mysql restart

+ 79 - 0
vagrant/provision/once-as-root.sh

@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+
+#== Import script args ==
+
+timezone=$(echo "$1")
+readonly IP=$2
+
+#== Bash helpers ==
+
+function info {
+  echo " "
+  echo "--> $1"
+  echo " "
+}
+
+#== Provision script ==
+
+info "Provision-script user: `whoami`"
+
+export DEBIAN_FRONTEND=noninteractive
+
+info "Configure timezone"
+timedatectl set-timezone ${timezone} --no-ask-password
+
+info "Add the VM IP to the list of allowed IPs"
+awk -v ip=$IP -f /app/vagrant/provision/provision.awk /app/config/web.php
+
+info "Prepare root password for MySQL"
+debconf-set-selections <<< 'mariadb-server mysql-server/root_password password'
+debconf-set-selections <<< 'mariadb-server mysql-server/root_password_again password'
+echo "Done!"
+
+info "Update OS software"
+apt-get update
+apt-get upgrade -y
+
+info "Install additional software"
+apt-get install -y php7.2-curl php7.2-cli php7.2-intl php7.2-mysqlnd php7.2-gd php7.2-fpm php7.2-mbstring php7.2-xml unzip nginx mariadb-server-10.1 php.xdebug
+
+info "Configure MySQL"
+sed -i 's/.*bind-address.*/bind-address = 0.0.0.0/' /etc/mysql/mariadb.conf.d/50-server.cnf
+mysql <<< "CREATE USER 'root'@'%' IDENTIFIED BY ''"
+mysql <<< "GRANT ALL PRIVILEGES ON *.* TO 'root'@'%'"
+mysql <<< "DROP USER 'root'@'localhost'"
+mysql <<< 'FLUSH PRIVILEGES'
+echo "Done!"
+
+info "Configure PHP-FPM"
+sed -i 's/user = www-data/user = vagrant/g' /etc/php/7.2/fpm/pool.d/www.conf
+sed -i 's/group = www-data/group = vagrant/g' /etc/php/7.2/fpm/pool.d/www.conf
+sed -i 's/owner = www-data/owner = vagrant/g' /etc/php/7.2/fpm/pool.d/www.conf
+cat << EOF > /etc/php/7.2/mods-available/xdebug.ini
+zend_extension=xdebug.so
+xdebug.remote_enable=1
+xdebug.remote_connect_back=1
+xdebug.remote_port=9000
+xdebug.remote_autostart=1
+EOF
+echo "Done!"
+
+info "Configure NGINX"
+sed -i 's/user www-data/user vagrant/g' /etc/nginx/nginx.conf
+echo "Done!"
+
+info "Enabling site configuration"
+ln -s /app/vagrant/nginx/app.conf /etc/nginx/sites-enabled/app.conf
+echo "Done!"
+
+info "Removing default site configuration"
+rm /etc/nginx/sites-enabled/default
+echo "Done!"
+
+info "Initailize databases for MySQL"
+mysql <<< 'CREATE DATABASE yii2basic'
+mysql <<< 'CREATE DATABASE yii2basic_test'
+echo "Done!"
+
+info "Install composer"
+curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

+ 31 - 0
vagrant/provision/once-as-vagrant.sh

@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+
+#== Import script args ==
+
+github_token=$(echo "$1")
+
+#== Bash helpers ==
+
+function info {
+  echo " "
+  echo "--> $1"
+  echo " "
+}
+
+#== Provision script ==
+
+info "Provision-script user: `whoami`"
+
+info "Configure composer"
+composer config --global github-oauth.github.com ${github_token}
+echo "Done!"
+
+info "Install project dependencies"
+cd /app
+composer --no-progress --prefer-dist install
+
+info "Create bash-alias 'app' for vagrant user"
+echo 'alias app="cd /app"' | tee /home/vagrant/.bash_aliases
+
+info "Enabling colorized prompt for guest console"
+sed -i "s/#force_color_prompt=yes/force_color_prompt=yes/" /home/vagrant/.bashrc

+ 50 - 0
vagrant/provision/provision.awk

@@ -0,0 +1,50 @@
+###
+# Modifying Yii2's files for Vagrant VM
+#
+# @author HA3IK <golubha3ik@gmail.com>
+# @version 1.0.0
+
+BEGIN {
+    print "AWK BEGINs its work:"
+    IGNORECASE = 1
+
+    # Correct IP - wildcard last octet
+    match(ip, /(([0-9]+\.)+)/, arr)
+    ip = arr[1] "*"
+}
+# BODY
+{
+    # Check if it's the same file
+    if (FILENAME != isFile["same"]){
+        msg = "- Work with: " FILENAME
+        # Close a previous file
+        close(isFile["same"])
+        # Delete previous data
+        delete isFile
+        # Save current file
+        isFile["same"] = FILENAME
+        # Define array index for the file
+        switch (FILENAME){
+        case /config\/web\.php$/:
+            isFile["IsConfWeb"] = 1
+            msg = msg " - add allowed IP: " ip
+            break
+        }
+        # Print the concatenated message for the file
+        print msg
+    }
+
+    # IF config/web.php
+    if (isFile["IsConfWeb"]){
+        # IF line has "allowedIPs" and doesn't has our IP
+        if (match($0, "allowedIPs") && !match($0, ip)){
+            match($0, /([^\]]+)(.+)/, arr)
+            $0 = sprintf("%s, '%s'%s", arr[1], ip, arr[2])
+        }
+        # Rewrite the file
+        print $0 > FILENAME
+    }
+}
+END {
+    print "AWK ENDs its work."
+}

+ 38 - 0
views/inicio/index.php

@@ -0,0 +1,38 @@
+<style>
+  .site-card {
+    height: 100vh;
+    width: 100vw;
+    padding: 20px;
+    background: #ececec;
+  }
+  .content {
+    width: 100%;
+    height: 100%;
+  }
+  .grid {
+    width: 25%;
+    text-align: center;
+  }
+</style>
+<script type="text/babel">
+  const { LoadingOutlined } = window.icons;
+  const { Card } = window.antd;
+
+  const App = () => {
+
+    return (
+      <div className="site-card">
+        <Card>
+          <a href="/site/consulta">
+            <Card.Grid className="grid">Consultas</Card.Grid>
+          </a>
+          <a href="/site/asignar-descarga">
+            <Card.Grid className="grid">Asignar Descarga</Card.Grid>
+          </a>
+        </Card>
+      </div>
+    );
+  }
+
+  ReactDOM.render(<App />, document.getElementById('root'));
+</script>

+ 18 - 0
views/layouts/main.php

@@ -0,0 +1,18 @@
+<!doctype html>
+<html lang="en-us">
+  <head>
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/antd/4.17.3/antd.min.css" integrity="sha512-JN9ss44dcla+UBp5JAQg+34kuQPv/ev3r3XWBBxcSTnnoE8nhk8ydhwZt2nXROCpXcehkblGJHtFOkKfVX2HQA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
+    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
+    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
+    <script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
+    <script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
+    <script src='https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.js'></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment-with-locales.min.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/antd/4.17.3/antd-with-locales.min.js" integrity="sha512-fsySfnXbN3brkGozJ3JF/VGiDN6PxOmaqu8mWW+oENXrx3mpekinx3iyAavbcnSU7Xf2ZCm9JLjmnbI3Dz/prw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/ant-design-icons/4.7.0/index.umd.min.js" integrity="sha512-z5FNRTyztWZ5s1kKiRNfN5EZ2OX8QnV9nEU7n/vKPXLlvfUQDFoXVswtHL1FQeuFY2acFX2FlYIsjFGZ/5OHbw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
+  </head>
+  <body>
+    <div id="root"></div>
+    <?= $content ?>
+  </body>
+</html>

+ 87 - 0
views/login/index.php

@@ -0,0 +1,87 @@
+<style>
+  .site-card {
+    height: 100vh;
+    width: 100vw;
+    padding: 20px;
+    background: #ececec;
+  }
+  .content {
+    width: 100%;
+    height: 100%;
+  }
+  .grid {
+    width: 25%;
+    text-align: center;
+  }
+</style>
+<script type="text/babel">
+  const { LoadingOutlined } = window.icons;
+  const { Card, Form, Input, Button, Row, Col, Modal } = window.antd;
+
+  const App = () => {
+
+    let onFinish = async (f) => {
+      let request = {
+        "method": "POST",
+        "headers": {
+          "Content-Type": "application/json",
+          "<?= \Yii::$app->getRequest()->csrfParam ?>": "<?= \Yii::$app->getRequest()->csrfToken ?>", 
+        },
+        body: JSON.stringify({
+          "usuario": f.usuario,
+          "clave": f.clave
+        })
+      };
+      let resp = await fetch("/login", request);
+      let data = await resp.json();
+      if(data && data.success) {
+        window.location.href = "/";
+        return;
+      }
+
+      Modal.warning({title: "Los datos ingresados son incorrectos"});
+    }
+
+    return (
+      <div className="site-card">
+        <Row>
+          <Col span={8} offset={8}>
+            <Card>
+              <Form
+                name="basic"
+                labelCol={{ span: 8 }}
+                wrapperCol={{ span: 16 }}
+                onFinish={onFinish}
+                autoComplete="off"
+                >
+                <Form.Item
+                  label="Usuario"
+                  name="usuario"
+                  rules={[{ required: true, message: 'El Correo es obligatorio' }]}
+                >
+                  <Input />
+                </Form.Item>
+
+                <Form.Item
+                  label="Contraseña"
+                  name="clave"
+                  rules={[{ required: true, message: 'La Contraseña es obligatoria' }]}
+                >
+                  <Input.Password />
+                </Form.Item>
+
+                <Form.Item wrapperCol={{ offset: 8, span: 16 }}>
+                  <Button type="primary" htmlType="submit">
+                    Iniciar Sesión
+                  </Button>
+                </Form.Item>
+              </Form>
+            </Card>
+          </Col>
+        </Row>
+      </div>
+    );
+  }
+
+  ReactDOM.render(<App />, document.getElementById('root'));
+</script>

+ 318 - 0
views/site/asignar-descarga.php

@@ -0,0 +1,318 @@
+<style>
+  .site-card {
+    min-height: 100vh;
+    width: 100vw;
+    padding: 20px;
+    background: #ececec;
+  }
+  .content {
+    width: 100%;
+    height: 100%;
+  }
+
+  .full-width {
+    width: 100%;
+  }
+</style>
+<script type="text/babel">
+  const url = "https://api.fourier.audio";
+  const urlDescarga = "https://descarga.fourier.audio";
+  const { LoadingOutlined } = window.icons;
+  const { Card, Select, Spin, Row, Col, Form, Input, Button, Modal, DatePicker, Table, Popconfirm, Checkbox } = window.antd;
+  const { Option } = Select;
+  const { RangePicker } = DatePicker;
+
+  const App = () => {
+    const [loading, setLoading] = React.useState(true);
+    const [estDisabled, setEstDisabled] = React.useState(false);
+    const [grupos, setGrupos] = React.useState([]);
+    const [estaciones, setEstaciones] = React.useState([]);
+    const [estacion, setEstacion] = React.useState(null);
+    const [modalVisible, setModalVisible] = React.useState(false);
+    const [reiniciar, setReiniciar] = React.useState(false);
+    const [data, setData] = React.useState([]);
+    const [debug, setDebug] = React.useState(null);
+    const [form] = Form.useForm();
+
+    const loadingIcon = <LoadingOutlined style={{ fontSize: 24 }} spin />;
+
+    let obtenerGrupos = async () => {
+      const resp = await fetch(`${url}/na/grupo`)
+      const json = await resp.json();
+      setLoading(false);
+      setGrupos(json.resultado);
+    }
+
+    React.useEffect(() => {
+      obtenerGrupos();
+    }, []);
+
+    const onCityChange = async (id) => {
+      setEstDisabled(true);
+      const resp = await fetch(`${url}/na/grupo?id=${id}&expand=ciudades.monitoreable&ordenar=nombre`)
+      const json = await resp.json();
+
+      let ciudad = json.resultado[0].ciudades[0];
+      let _estaciones = [];
+
+      for(let estacion of ciudad.monitoreable) {
+        _estaciones.push({
+          "idCiudad": ciudad.id,
+          "idEstacion": estacion.id,
+          "clave": estacion.clave,
+          "frecuencia": estacion.frecuencia,
+          "siglas": estacion.siglas
+        })
+      }
+ 
+      setEstaciones(_estaciones);
+      setEstDisabled(false); 
+    }
+
+    const onStationChange = async (id) => {
+      for(let est of estaciones) {
+        if(est.idEstacion == id) {
+          setEstacion(est);
+          break;
+        }
+      }
+    }
+
+    const confirmar = (f) => {
+      let body = {
+        idPc: f.idPc,
+        fi: f.fechas[0].format(),
+        ff: f.fechas[1].format(),
+        reiniciar: reiniciar,
+        estaciones: data
+      }
+
+      const fi = f.fechas[0].format("DD/MM/YYYY");
+      const ff = f.fechas[1].format("DD/MM/YYYY");
+
+      Modal.confirm({
+        title: `¿Guardar ${body.estaciones.length} estaciones en PC "${body.idPc}" desde ${fi} hasta ${ff}?`,
+        // content: <pre>{JSON.stringify(data)}</pre>,
+        onOk: () => { handleOk(body) } ,
+      });
+    }
+
+    const handleOk = async (cuerpo) => {
+      let resp = await fetch(`/descarga/guardar`, {
+        method: "POST",
+        body: JSON.stringify(cuerpo),
+        headers: {
+          "Content-Type": "application/json"
+        }
+      });
+
+      setDebug(cuerpo);
+
+      // form.setFieldsValue({ idPc: '' });
+      // setData([]);
+    }
+
+    const agregarEstacion = () => {
+      if(estacion === null) {
+        Modal.warning({
+          title: 'No eligió una estación',
+          content: 'Por favor seleccione una estación'
+        });
+        return;
+      }
+
+      const found = data.find(e => e.idEstacion === estacion.idEstacion);
+      if(found) {
+        Modal.warning({
+          title: `La Estación ${estacion.siglas} ya se encuentra en la lista`
+        });
+        return;
+      }
+
+      let _data = [...data];
+      _data.push({
+        key: `${estacion.idCiudad}-${estacion.idEstacion}`,
+        ...estacion,
+      });
+
+      form.setFieldsValue({ idEstacion: null })
+      setEstacion(null);
+      setData(_data);
+
+      return;
+    }
+
+    const obtenerEstacionDelPC = async () => {
+      let idPc = form.getFieldValue('idPc');
+      if(!idPc) {
+        Modal.warning({
+          title: 'No eligió un PC',
+          content: 'Por favor seleccione un PC'
+        });
+        return;
+      }
+
+      let resp = await fetch(`/estacion/por-pc?pc=${idPc}`);
+      let data = await resp.json();
+
+      setData(data.resultado);
+
+    }
+
+    const eliminar = (key) => {
+      const _data = [...data];
+      setData(_data.filter(item => item.key !== key) );
+    }
+
+    const columnas = [
+      {
+        title: 'ID Ciudad',
+        dataIndex: 'idCiudad',
+        key: '1',
+      },
+      {
+        title: 'Clave',
+        dataIndex: 'clave',
+        key: '2',
+      },
+      {
+        title: 'Eliminar',
+        key: '3',
+        render(r) {
+          return (
+            <Popconfirm 
+              title="¿Eliminar?" 
+              okText="OK"
+              cancelText="Cancelar"
+              onConfirm={() => eliminar(r.key)}>
+              <a>Eliminar</a>
+            </Popconfirm>
+          )
+        }
+      }
+    ];
+
+    return (
+      <div className="site-card">
+        { loading 
+          ? <Spin indicator={loadingIcon} />
+          : <Card className="content">
+              <Form
+                form={form}
+                layout="vertical"
+                onFinish={confirmar}
+                >
+                <Row gutter={8}>
+                  <Col span={6}>
+                    <Form.Item
+                      label="ID PC"
+                      name="idPc"
+                      rules={[{ required: true, message: 'Ingresa el ID del PC' }]}
+                    >
+                      <Input size="large" />
+                    </Form.Item>
+                  </Col>
+                  <Col span={6}>
+                    <Form.Item
+                      label="Rango de Fechas"
+                      name="fechas"
+                      rules={[{ required: true, message: 'Ingresa el Rango de Fechas' }]}
+                    >
+                      <RangePicker 
+                        showTime={{
+                          hideDisabledOptions: true,
+                          defaultValue: [moment('00:00:00', 'HH:mm:ss'), moment('23:59:59', 'HH:mm:ss')],
+                        }}
+                        format="DD-MM-YYYY HH:mm:ss"
+                        size="large"
+                        />
+                    </Form.Item>
+                  </Col>
+                  <Col span={6}>
+                    <Form.Item
+                      label="&nbsp;"
+                      name="reiniciar"
+                    >
+                      <Checkbox size="large" checked={reiniciar} onChange={(e) => { setReiniciar(e.target.checked) }}>
+                        ¿Reiniciar descargas?
+                      </Checkbox>
+                    </Form.Item>
+                  </Col>
+                </Row>
+                <Row gutter={8}>
+                  <Col span={6}>
+                    <Form.Item
+                      label="Ciudad"
+                      name="idCiudad"
+                      >
+                      <Select
+                        showSearch
+                        size="large"
+                        style={{ width: '100%' }}
+                        placeholder="Elige un grupo"
+                        optionFilterProp="children"
+                        onChange={onCityChange}
+                        filterOption={(input, option) =>
+                          option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
+                        }
+                        >
+                        { grupos && grupos.map((item) => { return <Option key={`grupo-${item.id}`} value={item.id}>{item.nombre}</Option> }) }
+                      </Select>
+                    </Form.Item>
+                  </Col>
+                  <Col span={6}>
+                    <Form.Item
+                      label="Estación"
+                      name="idEstacion"
+                      >
+                      <Select
+                        disabled={estDisabled}
+                        showSearch
+                        size="large"
+                        style={{ width: '100%' }}
+                        placeholder="Elige una estación"
+                        optionFilterProp="children"
+                        onChange={onStationChange}
+                        filterOption={(input, option) =>
+                          option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
+                        }
+                        >
+                        { estaciones && estaciones.map((item) => { return <Option key={`est-${item.idEstacion}`} value={item.idEstacion}>{item.frecuencia} - {item.siglas} - {item.clave}</Option> }) }
+                      </Select>
+                    </Form.Item>
+                  </Col>
+                  <Col span={3}>
+                    <Form.Item label="&nbsp;" >
+                      <Button className="full-width" size="large" onClick={agregarEstacion}>Agregar</Button>
+                    </Form.Item>
+                  </Col>
+                </Row>
+                <Row gutter={8}>
+                  <Col span={12}>
+                    <Table className="full-width" dataSource={data} columns={columnas} />
+                  </Col>
+                  <Col span={3}>
+                    <Button className="full-width" size="large" onClick={obtenerEstacionDelPC}>Estaciones del PC</Button>
+                  </Col>
+                </Row>
+                <Row>
+                  <Col span={6}>
+                    <Form.Item label="&nbsp;" >
+                      <Button type="primary" htmlType="submit" size="large" style={{width: '100%'}}>Guardar</Button>
+                    </Form.Item>
+                  </Col>
+                </Row>
+              </Form>
+              <Row>
+                <Col className="full-width">
+                  {debug && <pre className="full-width">{JSON.stringify(debug)}</pre>}
+                </Col>
+              </Row>
+            </Card>
+        }
+      </div>
+    );
+  }
+
+  ReactDOM.render(<App />, document.getElementById('root'));
+</script>

+ 137 - 0
views/site/consulta.php

@@ -0,0 +1,137 @@
+<script type="text/javascript" src="https://unpkg.com/@ant-design/graphs@latest/dist/graphs.min.js"></script>
+
+<style>
+  .site-card {
+    height: 100vh;
+    width: 100vw;
+    padding: 20px;
+    background: #ececec;
+  }
+  .content {
+    width: 100%;
+    height: 100%;
+  }
+  .grid {
+    width: 25%;
+    text-align: center;
+  }
+  .full-width {
+    width: 100%;
+  }
+</style>
+<script type="text/babel">
+  const url = "https://descarga.fourier.audio";
+  const { LoadingOutlined } = window.icons;
+  const { Card, Form, Input, Row, Col, Button, DatePicker, Spin, Table } = window.antd;
+  const { RangePicker } = DatePicker;
+
+  const App = () => {
+
+    const [cargando, setCargando] = React.useState(false);
+    const [descargas, setDescargas] = React.useState([]);
+    const [inicio, setInicio] = React.useState(null);
+    const [form]  = Form.useForm();
+
+    const onFinish = async (data) => {
+      setCargando(true);
+      const idPc = data.idPc;
+      const fi = data.fechas[0].format("YYYY-MM-DD HH:mm:ss");
+      const ff = data.fechas[1].format("YYYY-MM-DD HH:mm:ss");
+
+      const resp = await fetch(`${url}/descarga/por-pc?pc=${idPc}&fi=${fi}&ff=${ff}`);
+      const res = await resp.json();
+
+      setDescargas(res.resultado);
+      setCargando(false);
+    }
+
+    let onCalendarChange = (mf, f, r) => {
+      if(r.range === "start") {
+        setInicio(mf[0]);
+      }
+    }
+
+    let disabledDate = (current) => {
+      if(inicio === null) {
+        return false;
+      } else {
+        let disabled = current < inicio.add(7, 'days')
+        console.log(!disabled, current, inicio.add(7, 'days'))
+        return !disabled;
+      }
+    }
+
+    const columnas = [
+      {
+        title: 'Estación',
+        dataIndex: 'clave',
+        key: '1',
+      },
+      {
+        title: 'Fecha',
+        dataIndex: 'fecha',
+        key: '2',
+      },
+      {
+        title: 'Descargas',
+        key: '3',
+        render(r) {
+          return (<div>{r.descargados}/{r.pendientes + r.descargados}</div>)
+        }
+      }
+    ];
+
+    return (
+      <div className="site-card">
+        <Card className="content">
+          <Form
+            form={form}
+            layout="vertical"
+            onFinish={onFinish}
+            >
+            <Row gutter={8}>
+              <Col span={6}>
+                <Form.Item
+                  label="ID PC"
+                  name="idPc"
+                  rules={[{ required: true, message: 'Ingresa el ID del PC' }]}
+                >
+                  <Input size="large" />
+                </Form.Item>
+              </Col>
+              <Col span={6}>
+                <Form.Item
+                  label="Fechas"
+                  name="fechas"
+                  rules={[{ required: true, message: 'Ingresa el Rango de Fechas' }]}
+                >
+                  <RangePicker 
+                    showTime={{
+                      hideDisabledOptions: true,
+                      defaultValue: [moment('00:00:00', 'HH:mm:ss'), moment('23:59:59', 'HH:mm:ss')],
+                    }}
+                    format="DD-MM-YYYY HH:mm:ss"
+                    size="large"
+                    />
+                </Form.Item>
+              </Col>
+              <Col span={6}>
+                <Form.Item label="&nbsp;" >
+                  <Button type="primary" htmlType="submit" size="large">Consultar</Button>
+                </Form.Item>
+              </Col>
+            </Row>
+          </Form>
+          <Row>
+            { cargando && <LoadingOutlined style={{ fontSize: 24 }} spin />}
+            <Col span={16}>
+              <Table className="full-width" dataSource={descargas} columns={columnas} />
+            </Col>
+          </Row>
+        </Card>
+      </div>
+    );
+  }
+
+  ReactDOM.render(<App />, document.getElementById('root'));
+</script>

+ 27 - 0
views/site/error.php

@@ -0,0 +1,27 @@
+<?php
+
+/* @var $this yii\web\View */
+/* @var $name string */
+/* @var $message string */
+/* @var $exception Exception */
+
+use yii\helpers\Html;
+
+$this->title = $name;
+?>
+<div class="site-error">
+
+    <h1><?= Html::encode($this->title) ?></h1>
+
+    <div class="alert alert-danger">
+        <?= nl2br(Html::encode($message)) ?>
+    </div>
+
+    <p>
+        The above error occurred while the Web server was processing your request.
+    </p>
+    <p>
+        Please contact us if you think this is a server error. Thank you.
+    </p>
+
+</div>

+ 4 - 0
web/.htaccess

@@ -0,0 +1,4 @@
+RewriteEngine on
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteRule . index.php [L]

+ 2 - 0
web/assets/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 84 - 0
web/css/site.css

@@ -0,0 +1,84 @@
+main > .container {
+    padding: 70px 15px 20px;
+}
+
+.footer {
+    background-color: #f5f5f5;
+    font-size: .9em;
+    height: 60px;
+}
+
+.footer > .container {
+    padding-right: 15px;
+    padding-left: 15px;
+}
+
+.not-set {
+    color: #c55;
+    font-style: italic;
+}
+
+/* add sorting icons to gridview sort links */
+a.asc:after, a.desc:after {
+    content: '';
+    left: 3px;
+    display: inline-block;
+    width: 0;
+    height: 0;
+    border: solid 5px transparent;
+    margin: 4px 4px 2px 4px;
+    background: transparent;
+}
+
+a.asc:after {
+    border-bottom: solid 7px #212529;
+    border-top-width: 0;
+}
+
+a.desc:after {
+    border-top: solid 7px #212529;
+    border-bottom-width: 0;
+}
+
+.grid-view th {
+    white-space: nowrap;
+}
+
+.hint-block {
+    display: block;
+    margin-top: 5px;
+    color: #999;
+}
+
+.error-summary {
+    color: #a94442;
+    background: #fdf7f7;
+    border-left: 3px solid #eed3d7;
+    padding: 10px 20px;
+    margin: 0 0 15px 0;
+}
+
+/* align the logout "link" (button in form) of the navbar */
+.nav li > form > button.logout {
+    padding-top: 7px;
+    color: rgba(255, 255, 255, 0.5);
+}
+
+@media(max-width:767px) {
+    .nav li > form > button.logout {
+        display:block;
+        text-align: left;
+        width: 100%;
+        padding: 10px 0;
+    }
+}
+
+.nav > li > form > button.logout:focus,
+.nav > li > form > button.logout:hover {
+    text-decoration: none;
+    color: rgba(255, 255, 255, 0.75);
+}
+
+.nav > li > form > button.logout:focus {
+    outline: none;
+}

BIN
web/favicon.ico


+ 16 - 0
web/index-test.php

@@ -0,0 +1,16 @@
+<?php
+
+// NOTE: Make sure this file is not accessible when deployed to production
+if (!in_array(@$_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
+    die('You are not allowed to access this file.');
+}
+
+defined('YII_DEBUG') or define('YII_DEBUG', true);
+defined('YII_ENV') or define('YII_ENV', 'test');
+
+require __DIR__ . '/../vendor/autoload.php';
+require __DIR__ . '/../vendor/yiisoft/yii2/Yii.php';
+
+$config = require __DIR__ . '/../config/test.php';
+
+(new yii\web\Application($config))->run();

+ 12 - 0
web/index.php

@@ -0,0 +1,12 @@
+<?php
+
+// comment out the following two lines when deployed to production
+defined('YII_DEBUG') or define('YII_DEBUG', true);
+defined('YII_ENV') or define('YII_ENV', 'dev');
+
+require __DIR__ . '/../vendor/autoload.php';
+require __DIR__ . '/../vendor/yiisoft/yii2/Yii.php';
+
+$config = require __DIR__ . '/../config/web.php';
+
+(new yii\web\Application($config))->run();

+ 2 - 0
web/robots.txt

@@ -0,0 +1,2 @@
+User-agent: *
+Disallow:

+ 76 - 0
widgets/Alert.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace app\widgets;
+
+use Yii;
+
+/**
+ * Alert widget renders a message from session flash. All flash messages are displayed
+ * in the sequence they were assigned using setFlash. You can set message as following:
+ *
+ * ```php
+ * Yii::$app->session->setFlash('error', 'This is the message');
+ * Yii::$app->session->setFlash('success', 'This is the message');
+ * Yii::$app->session->setFlash('info', 'This is the message');
+ * ```
+ *
+ * Multiple messages could be set as follows:
+ *
+ * ```php
+ * Yii::$app->session->setFlash('error', ['Error 1', 'Error 2']);
+ * ```
+ *
+ * @author Kartik Visweswaran <kartikv2@gmail.com>
+ * @author Alexander Makarov <sam@rmcreative.ru>
+ */
+class Alert extends \yii\bootstrap4\Widget
+{
+    /**
+     * @var array the alert types configuration for the flash messages.
+     * This array is setup as $key => $value, where:
+     * - key: the name of the session flash variable
+     * - value: the bootstrap alert type (i.e. danger, success, info, warning)
+     */
+    public $alertTypes = [
+        'error'   => 'alert-danger',
+        'danger'  => 'alert-danger',
+        'success' => 'alert-success',
+        'info'    => 'alert-info',
+        'warning' => 'alert-warning'
+    ];
+    /**
+     * @var array the options for rendering the close button tag.
+     * Array will be passed to [[\yii\bootstrap\Alert::closeButton]].
+     */
+    public $closeButton = [];
+
+
+    /**
+     * {@inheritdoc}
+     */
+    public function run()
+    {
+        $session = Yii::$app->session;
+        $flashes = $session->getAllFlashes();
+        $appendClass = isset($this->options['class']) ? ' ' . $this->options['class'] : '';
+
+        foreach ($flashes as $type => $flash) {
+            if (!isset($this->alertTypes[$type])) {
+                continue;
+            }
+
+            foreach ((array) $flash as $i => $message) {
+                echo \yii\bootstrap4\Alert::widget([
+                    'body' => $message,
+                    'closeButton' => $this->closeButton,
+                    'options' => array_merge($this->options, [
+                        'id' => $this->getId() . '-' . $type . '-' . $i,
+                        'class' => $this->alertTypes[$type] . $appendClass,
+                    ]),
+                ]);
+            }
+
+            $session->removeFlash($type);
+        }
+    }
+}

+ 21 - 0
yii

@@ -0,0 +1,21 @@
+#!/usr/bin/env php
+<?php
+/**
+ * Yii console bootstrap file.
+ *
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright (c) 2008 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+defined('YII_DEBUG') or define('YII_DEBUG', true);
+defined('YII_ENV') or define('YII_ENV', 'dev');
+
+require __DIR__ . '/vendor/autoload.php';
+require __DIR__ . '/vendor/yiisoft/yii2/Yii.php';
+
+$config = require __DIR__ . '/config/console.php';
+
+$application = new yii\console\Application($config);
+$exitCode = $application->run();
+exit($exitCode);

+ 20 - 0
yii.bat

@@ -0,0 +1,20 @@
+@echo off
+
+rem -------------------------------------------------------------
+rem  Yii command line bootstrap script for Windows.
+rem
+rem  @author Qiang Xue <qiang.xue@gmail.com>
+rem  @link http://www.yiiframework.com/
+rem  @copyright Copyright (c) 2008 Yii Software LLC
+rem  @license http://www.yiiframework.com/license/
+rem -------------------------------------------------------------
+
+@setlocal
+
+set YII_PATH=%~dp0
+
+if "%PHP_COMMAND%" == "" set PHP_COMMAND=php.exe
+
+"%PHP_COMMAND%" "%YII_PATH%yii" %*
+
+@endlocal