new PHP ratings service
This commit is contained in:
2
.env
2
.env
@@ -1,3 +1,3 @@
|
|||||||
# environment file for docker-compose
|
# environment file for docker-compose
|
||||||
REPO=robotshop
|
REPO=robotshop
|
||||||
TAG=0.2.9
|
TAG=php
|
||||||
|
@@ -65,8 +65,8 @@ services:
|
|||||||
condition: on-failure
|
condition: on-failure
|
||||||
mysql:
|
mysql:
|
||||||
build:
|
build:
|
||||||
context: shipping/database
|
context: mysql
|
||||||
image: ${REPO}/rs-shipping-db:${TAG}
|
image: ${REPO}/rs-mysql-db:${TAG}
|
||||||
networks:
|
networks:
|
||||||
- robot-shop
|
- robot-shop
|
||||||
deploy:
|
deploy:
|
||||||
@@ -75,7 +75,7 @@ services:
|
|||||||
condition: on-failure
|
condition: on-failure
|
||||||
shipping:
|
shipping:
|
||||||
build:
|
build:
|
||||||
context: shipping/service
|
context: shipping
|
||||||
image: ${REPO}/rs-shipping:${TAG}
|
image: ${REPO}/rs-shipping:${TAG}
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql
|
- mysql
|
||||||
@@ -85,6 +85,18 @@ services:
|
|||||||
replicas: 1
|
replicas: 1
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
|
ratings:
|
||||||
|
build:
|
||||||
|
context: ratings
|
||||||
|
image: ${REPO}/rs-ratings:${TAG}
|
||||||
|
networks:
|
||||||
|
- robot-shop
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
payment:
|
payment:
|
||||||
build:
|
build:
|
||||||
context: payment
|
context: payment
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
from locust import HttpLocust, TaskSet, task
|
from locust import HttpLocust, TaskSet, task
|
||||||
from random import choice
|
from random import choice
|
||||||
|
from random import randint
|
||||||
|
|
||||||
class UserBehavior(TaskSet):
|
class UserBehavior(TaskSet):
|
||||||
def on_start(self):
|
def on_start(self):
|
||||||
@@ -33,7 +34,12 @@ class UserBehavior(TaskSet):
|
|||||||
if item['instock'] != 0:
|
if item['instock'] != 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# vote for item
|
||||||
|
if randint(1, 10) <= 3:
|
||||||
|
self.client.put('/api/ratings/api/rate/{}/{}'.format(item['sku'], randint(1, 5)))
|
||||||
|
|
||||||
self.client.get('/api/catalogue/product/{}'.format(item['sku']))
|
self.client.get('/api/catalogue/product/{}'.format(item['sku']))
|
||||||
|
self.client.get('/api/ratings/api/fetch/{}'.format(item['sku']))
|
||||||
self.client.get('/api/cart/add/{}/{}/1'.format(uniqueid, item['sku']))
|
self.client.get('/api/cart/add/{}/{}/1'.format(uniqueid, item['sku']))
|
||||||
|
|
||||||
cart = self.client.get('/api/cart/cart/{}'.format(uniqueid)).json()
|
cart = self.client.get('/api/cart/cart/{}'.format(uniqueid)).json()
|
||||||
|
16
mysql/scripts/20-ratings.sql
Normal file
16
mysql/scripts/20-ratings.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE DATABASE ratings
|
||||||
|
DEFAULT CHARACTER SET 'utf8';
|
||||||
|
|
||||||
|
USE ratings;
|
||||||
|
|
||||||
|
CREATE TABLE ratings (
|
||||||
|
sku varchar(80) NOT NULL,
|
||||||
|
avg_rating DECIMAL(3, 2) NOT NULL,
|
||||||
|
rating_count INT NOT NULL,
|
||||||
|
PRIMARY KEY (sku)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
|
||||||
|
GRANT ALL ON ratings.* TO 'ratings'@'%'
|
||||||
|
IDENTIFIED BY 'iloveit';
|
||||||
|
|
14
ratings/Dockerfile
Normal file
14
ratings/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM php:7.2-apache
|
||||||
|
|
||||||
|
RUN docker-php-ext-install pdo_mysql
|
||||||
|
|
||||||
|
# relax permissions on status
|
||||||
|
COPY status.conf /etc/apache2/mods-available/status.conf
|
||||||
|
# Enable Apache mod_rewrite and status
|
||||||
|
RUN a2enmod rewrite && a2enmod status
|
||||||
|
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
COPY html/ /var/www/html
|
||||||
|
|
6
ratings/html/.htaccess
Normal file
6
ratings/html/.htaccess
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule api/(.*)$ api.php?request=$1 [QSA,NC,L]
|
||||||
|
</IfModule>
|
94
ratings/html/API.class.php
Normal file
94
ratings/html/API.class.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
abstract class API {
|
||||||
|
protected $method = '';
|
||||||
|
|
||||||
|
protected $endpoint = '';
|
||||||
|
|
||||||
|
protected $verb = '';
|
||||||
|
|
||||||
|
protected $args = array();
|
||||||
|
|
||||||
|
protected $file = Null;
|
||||||
|
|
||||||
|
public function __construct($request) {
|
||||||
|
// CORS
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: *');
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$this->args = explode('/', rtrim($request, '/'));
|
||||||
|
$this->endpoint = array_shift($this->args);
|
||||||
|
|
||||||
|
if(array_key_exists(0, $this->args) && !is_numeric($this->args[0])) {
|
||||||
|
$this->verb = array_shift($this->args);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
if($this->method == 'POST' && array_key_exists('HTTP_X_METHOD', $_SERVER)) {
|
||||||
|
if($_SERVER['HTTP_X_HTTP_METHOD'] == 'DELETE') {
|
||||||
|
$this->method = 'DELETE';
|
||||||
|
} else if($_SERVER['HTTP_X_HTTP_METHOD'] == 'PUT') {
|
||||||
|
$this->method = 'PUT';
|
||||||
|
} else {
|
||||||
|
throw new Exception('Unexpected header');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch($this->method) {
|
||||||
|
case 'DELETE':
|
||||||
|
case 'POST':
|
||||||
|
$this->request = $this->_cleanInputs($_POST);
|
||||||
|
break;
|
||||||
|
case 'GET':
|
||||||
|
$this->request = $this->_cleanInputs($_GET);
|
||||||
|
break;
|
||||||
|
case 'PUT':
|
||||||
|
$this->request = $this->_cleanInputs($_GET);
|
||||||
|
$this->file = file_get_contents('php://input');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processAPI() {
|
||||||
|
if(method_exists($this, $this->endpoint)) {
|
||||||
|
try {
|
||||||
|
$result = $this->{$this->endpoint}();
|
||||||
|
return $this->_response($result, 200);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->_response($e->getMessage(), $e->getCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->_response("No endpoint: $this->endpoint", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _response($data, $status = 200) {
|
||||||
|
header('HTTP/1.1 ' . $status . ' ' . $this->_requestStatus($status));
|
||||||
|
return json_encode($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _cleanInputs($data) {
|
||||||
|
$clean_input = array();
|
||||||
|
|
||||||
|
if(is_array($data)) {
|
||||||
|
foreach($data as $k => $v) {
|
||||||
|
$clean_input[$k] = $this->_cleanInputs($v);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$clean_input = trim(strip_tags($data));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clean_input;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _requestStatus($code) {
|
||||||
|
$status = array(
|
||||||
|
200 => 'OK',
|
||||||
|
400 => 'Bad Request',
|
||||||
|
404 => 'Not Found',
|
||||||
|
405 => 'Method Not Allowed',
|
||||||
|
500 => 'Internal Server Error');
|
||||||
|
|
||||||
|
return (array_key_exists("$code", $status) ? $status["$code"] : $status['500']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
161
ratings/html/api.php
Normal file
161
ratings/html/api.php
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'API.class.php';
|
||||||
|
|
||||||
|
class RatingsAPI extends API {
|
||||||
|
public function __construct($request, $origin) {
|
||||||
|
parent::__construct($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function health() {
|
||||||
|
return 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function dump() {
|
||||||
|
$data = array();
|
||||||
|
$data['method'] = $this->method;
|
||||||
|
$data['verb'] = $this->verb;
|
||||||
|
$data = array_merge($data, array('args' => $this->args));
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ratings/fetch/sku
|
||||||
|
protected function fetch() {
|
||||||
|
if($this->method == 'GET' && isset($this->verb) && count($this->args) == 0) {
|
||||||
|
$sku = $this->verb;
|
||||||
|
$data = $this->_getRating($sku);
|
||||||
|
return $data;
|
||||||
|
} else {
|
||||||
|
throw new Exception('Bad request', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ratings/rate/sku/score
|
||||||
|
protected function rate() {
|
||||||
|
if($this->method == 'PUT' && isset($this->verb) && count($this->args) == 1) {
|
||||||
|
$sku = $this->verb;
|
||||||
|
$score = intval($this->args[0]);
|
||||||
|
$score = min(max(1, $score), 5);
|
||||||
|
|
||||||
|
if(! $this->_checkSku($sku)) {
|
||||||
|
throw new Exception("$sku not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rating = $this->_getRating($sku);
|
||||||
|
if($rating['avg_rating'] == 0) {
|
||||||
|
// not rated yet
|
||||||
|
$this->_insertRating($sku, $score);
|
||||||
|
} else {
|
||||||
|
// iffy maths
|
||||||
|
$newAvg = (($rating['avg_rating'] * $rating['rating_count']) + $score) / ($rating['rating_count'] + 1);
|
||||||
|
$this->_updateRating($sku, $newAvg, $rating['rating_count'] + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Exception('Bad request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _getRating($sku) {
|
||||||
|
$db = $this->_dbConnect();
|
||||||
|
if($db) {
|
||||||
|
$stmt = $db->prepare('select avg_rating, rating_count from ratings where sku = ?');
|
||||||
|
if($stmt->execute(array($sku))) {
|
||||||
|
$data = $stmt->fetch();
|
||||||
|
if($data) {
|
||||||
|
// for some reason avg_rating is return as a string
|
||||||
|
$data['avg_rating'] = floatval($data['avg_rating']);
|
||||||
|
return $data;
|
||||||
|
} else {
|
||||||
|
// nicer to return an empty record than throw 404
|
||||||
|
return array('avg_rating' => 0, 'rating_count' => 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Exception('Failed to query data', 500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Exception('Database connection error', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _updateRating($sku, $score, $count) {
|
||||||
|
$db = $this->_dbConnect();
|
||||||
|
if($db) {
|
||||||
|
$stmt = $db->prepare('update ratings set avg_rating = ?, rating_count = ? where sku = ?');
|
||||||
|
if(! $stmt->execute(array($score, $count, $sku))) {
|
||||||
|
throw new Exception('Failed to update data', 500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Exception('Database connection error', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _insertRating($sku, $score) {
|
||||||
|
$db = $this->_dbConnect();
|
||||||
|
if($db) {
|
||||||
|
$stmt = $db->prepare('insert into ratings(sku, avg_rating, rating_count) values(?, ?, ?)');
|
||||||
|
if(! $stmt->execute(array($sku, $score, 1))) {
|
||||||
|
throw new Exception('Failed to insert data', 500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Exception('Database connection error', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _dbConnect() {
|
||||||
|
$dsn = getenv('PDO_URL') ? getenv('PDO_URL') : 'mysql:host=mysql;dbname=ratings;charset=utf8mb4';
|
||||||
|
$opt = array(
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false
|
||||||
|
);
|
||||||
|
|
||||||
|
$db = false;
|
||||||
|
try {
|
||||||
|
$db = new PDO($dsn, 'ratings', 'iloveit', $opt);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$msg = $e->getMessage();
|
||||||
|
error_log("Database error $msg");
|
||||||
|
$db = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check sku exists in product catalogue
|
||||||
|
private function _checkSku($sku) {
|
||||||
|
$url = getenv('CATALOGUE_URL') ? getenv('CATALOGUE_URL') : 'http://catalogue:8080/';
|
||||||
|
$url = $url . 'product/' . $sku;
|
||||||
|
|
||||||
|
$opt = array(
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
);
|
||||||
|
$curl = curl_init($url);
|
||||||
|
curl_setopt_array($curl, $opt);
|
||||||
|
|
||||||
|
$data = curl_exec($curl);
|
||||||
|
if(! $data) {
|
||||||
|
throw new Exception('Failed to connect to catalogue', 500);
|
||||||
|
}
|
||||||
|
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||||
|
error_log("catalogue status $status");
|
||||||
|
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
return $status == 200;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!array_key_exists('HTTP_ORIGIN', $_SERVER)) {
|
||||||
|
$_SERVER['HTTP_ORIGIN'] = $_SERVER['SERVER_NAME'];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$API = new RatingsAPI($_REQUEST['request'], $_SERVER['HTTP_ORIGIN']);
|
||||||
|
echo $API->processAPI();
|
||||||
|
} catch(Exception $e) {
|
||||||
|
echo json_encode(Array('error' => $e->getMessage()));
|
||||||
|
}
|
||||||
|
?>
|
1
ratings/html/info.php
Normal file
1
ratings/html/info.php
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?php phpinfo(); ?>
|
29
ratings/status.conf
Normal file
29
ratings/status.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<IfModule mod_status.c>
|
||||||
|
# Allow server status reports generated by mod_status,
|
||||||
|
# with the URL of http://servername/server-status
|
||||||
|
# Uncomment and change the "192.0.2.0/24" to allow access from other hosts.
|
||||||
|
|
||||||
|
<Location /server-status>
|
||||||
|
SetHandler server-status
|
||||||
|
#Require local
|
||||||
|
#Require ip 192.0.2.0/24
|
||||||
|
</Location>
|
||||||
|
|
||||||
|
# Keep track of extended status information for each request
|
||||||
|
ExtendedStatus On
|
||||||
|
|
||||||
|
# Determine if mod_status displays the first 63 characters of a request or
|
||||||
|
# the last 63, assuming the request itself is greater than 63 chars.
|
||||||
|
# Default: Off
|
||||||
|
#SeeRequestTail On
|
||||||
|
|
||||||
|
|
||||||
|
<IfModule mod_proxy.c>
|
||||||
|
# Show Proxy LoadBalancer status in mod_status
|
||||||
|
ProxyStatus On
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
|
@@ -6,7 +6,8 @@ ENV CATALOGUE_HOST=catalogue \
|
|||||||
USER_HOST=user \
|
USER_HOST=user \
|
||||||
CART_HOST=cart \
|
CART_HOST=cart \
|
||||||
SHIPPING_HOST=shipping \
|
SHIPPING_HOST=shipping \
|
||||||
PAYMENT_HOST=payment
|
PAYMENT_HOST=payment \
|
||||||
|
RATINGS_HOST=ratings
|
||||||
|
|
||||||
COPY entrypoint.sh /root/
|
COPY entrypoint.sh /root/
|
||||||
ENTRYPOINT ["/root/entrypoint.sh"]
|
ENTRYPOINT ["/root/entrypoint.sh"]
|
||||||
|
@@ -63,6 +63,10 @@ server {
|
|||||||
proxy_pass http://${PAYMENT_HOST}:8080/;
|
proxy_pass http://${PAYMENT_HOST}:8080/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /api/ratings/ {
|
||||||
|
proxy_pass http://${RATINGS_HOST}:80/;
|
||||||
|
}
|
||||||
|
|
||||||
location /nginx_status {
|
location /nginx_status {
|
||||||
stub_status on;
|
stub_status on;
|
||||||
access_log off;
|
access_log off;
|
||||||
|
@@ -132,6 +132,17 @@ a.product:hover {
|
|||||||
width: 300px;
|
width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-star {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* login register */
|
/* login register */
|
||||||
table.credentials {
|
table.credentials {
|
||||||
border: 2px solid cyan;
|
border: 2px solid cyan;
|
||||||
|
@@ -187,6 +187,8 @@
|
|||||||
$scope.data = {};
|
$scope.data = {};
|
||||||
$scope.data.message = ' ';
|
$scope.data.message = ' ';
|
||||||
$scope.data.product = {};
|
$scope.data.product = {};
|
||||||
|
$scope.data.rating = {};
|
||||||
|
$scope.data.rating.avg_rating = 0;
|
||||||
$scope.data.quantity = 1;
|
$scope.data.quantity = 1;
|
||||||
|
|
||||||
$scope.addToCart = function() {
|
$scope.addToCart = function() {
|
||||||
@@ -207,6 +209,30 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.rateProduct = function(score) {
|
||||||
|
console.log('rate product', $scope.data.product.sku, score);
|
||||||
|
var url = '/api/ratings/api/rate/' + $scope.data.product.sku + '/' + score;
|
||||||
|
$http({
|
||||||
|
url: url,
|
||||||
|
method: 'PUT'
|
||||||
|
}).then((res) => {
|
||||||
|
$scope.data.message = 'Thankyou for your feedback';
|
||||||
|
$timeout(clearMessage, 3000);
|
||||||
|
loadRating($scope.data.product.sku);
|
||||||
|
}).catch((e) => {
|
||||||
|
console.log('ERROR', e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.glowstan = function(vote, val) {
|
||||||
|
console.log('glowstan', vote);
|
||||||
|
var idx = vote;
|
||||||
|
while(idx > 0) {
|
||||||
|
document.getElementById('vote-' + idx).style.opacity = val;
|
||||||
|
idx--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function loadProduct(sku) {
|
function loadProduct(sku) {
|
||||||
$http({
|
$http({
|
||||||
url: '/api/catalogue/product/' + sku,
|
url: '/api/catalogue/product/' + sku,
|
||||||
@@ -218,12 +244,24 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadRating(sku) {
|
||||||
|
$http({
|
||||||
|
url: '/api/ratings/api/fetch/' + sku,
|
||||||
|
method: 'GET'
|
||||||
|
}).then((res) => {
|
||||||
|
$scope.data.rating = res.data;
|
||||||
|
}).catch((e) => {
|
||||||
|
console.log('ERROR', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function clearMessage() {
|
function clearMessage() {
|
||||||
console.log('clear message');
|
console.log('clear message');
|
||||||
$scope.data.message = ' ';
|
$scope.data.message = ' ';
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProduct($routeParams.sku);
|
loadProduct($routeParams.sku);
|
||||||
|
loadRating($routeParams.sku);
|
||||||
});
|
});
|
||||||
|
|
||||||
robotshop.controller('cartform', function($scope, $http, $location, currentUser) {
|
robotshop.controller('cartform', function($scope, $http, $location, currentUser) {
|
||||||
|
@@ -8,6 +8,20 @@
|
|||||||
<div class="productimage">
|
<div class="productimage">
|
||||||
<img src="images/{{ data.product.sku }}.jpg"/>
|
<img src="images/{{ data.product.sku }}.jpg"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rating">
|
||||||
|
Rating
|
||||||
|
<span ng-if="data.rating.avg_rating != 0">
|
||||||
|
{{ data.rating.avg_rating.toFixed(1) }} from {{ data.rating.rating_count }} votes
|
||||||
|
</span>
|
||||||
|
<span ng-if="data.rating.avg_rating == 0">
|
||||||
|
No votes yet. Vote now.
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span ng-repeat="vote in [ 1, 2, 3, 4, 5 ]">
|
||||||
|
<img id="vote-{{ vote }}" class="vote-star" alt="{{ vote }}" ng-mouseover="glowstan(vote, 1.0);" ng-mouseleave="glowstan(vote, 0.5); "src="media/instana_icon_square.png" ng-click="rateProduct(vote);"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
{{ data.product.description }}
|
{{ data.product.description }}
|
||||||
</div>
|
</div>
|
||||||
@@ -22,3 +36,4 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user